diff --git a/.gitignore b/.gitignore
index 8309f03cfcfd..68965fb88586 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@ coverage.xml
# Sphinx documentation
docs/_build/
+doctrees/
# PyBuilder
target/
@@ -64,3 +65,5 @@ trail.py
sample_run.py
+
+o365_token\.txt
diff --git a/O365/account.py b/O365/account.py
index e5776914a44d..e50cb8e202b2 100644
--- a/O365/account.py
+++ b/O365/account.py
@@ -1,5 +1,10 @@
-from O365.connection import Connection, Protocol, MSGraphProtocol, oauth_authentication_flow
+from O365.address_book import AddressBook, GlobalAddressList
+from O365.calendar import Schedule
+from O365.connection import Connection, Protocol, MSGraphProtocol
+from O365.connection import oauth_authentication_flow
from O365.drive import Storage
+from O365.mailbox import MailBox
+from O365.message import Message
from O365.utils import ME_RESOURCE
from O365.message import Message
from O365.mailbox import MailBox
@@ -9,19 +14,25 @@
class Account(object):
- """ Class helper to integrate all components into a single object """
- def __init__(self, credentials, *, protocol=None, main_resource=ME_RESOURCE, **kwargs):
- """
- Account constructor.
- :param credentials: a tuple containing the client_id and client_secret
- :param protocol: the protocol to be used in this account instance
- :param main_resource: the resource to be used by this account
+ def __init__(self, credentials, *, protocol=None, main_resource=ME_RESOURCE,
+ **kwargs):
+ """ Creates an object which is used to access resources related to the
+ specified credentials
+
+ :param tuple credentials: a tuple containing the client_id
+ and client_secret
+ :param Protocol protocol: the protocol to be used in this account
+ :param str main_resource: the resource to be used by this account
+ ('me' or 'users')
:param kwargs: any extra args to be passed to the Connection instance
+ :raises ValueError: if an invalid protocol is passed
"""
- protocol = protocol or MSGraphProtocol # defaults to Graph protocol
- self.protocol = protocol(default_resource=main_resource, **kwargs) if isinstance(protocol, type) else protocol
+ protocol = protocol or MSGraphProtocol # Defaults to Graph protocol
+ self.protocol = protocol(default_resource=main_resource,
+ **kwargs) if isinstance(protocol,
+ type) else protocol
if not isinstance(self.protocol, Protocol):
raise ValueError("'protocol' must be a subclass of Protocol")
@@ -36,63 +47,96 @@ def __repr__(self):
return 'Unidentified Account'
def authenticate(self, *, scopes, **kwargs):
- """
- Performs the oauth authentication flow resulting in a stored token.
+ """ Performs the oauth authentication flow resulting in a stored token
It uses the credentials passed on instantiation
- :param scopes: a list of protocol user scopes to be converted by the protocol
- :param kwargs: other configuration to be passed to the Connection instance
+
+ :param list[str] scopes: list of protocol user scopes to be converted
+ by the protocol
+ :param kwargs: other configurations to be passed to the
+ Connection instance
+ :return: Success / Failure
+ :rtype: bool
"""
kwargs.setdefault('token_file_name', self.con.token_path.name)
- return oauth_authentication_flow(*self.con.auth, scopes=scopes, protocol=self.protocol, **kwargs)
+ return oauth_authentication_flow(*self.con.auth, scopes=scopes,
+ protocol=self.protocol, **kwargs)
@property
def connection(self):
- """ Alias for self.con """
+ """ Alias for self.con
+
+ :rtype: Connection
+ """
return self.con
def new_message(self, resource=None):
- """
- Creates a new message to be send or stored
- :param resource: Custom resource to be used in this message. Defaults to parent main_resource.
+ """ Creates a new message to be sent or stored
+
+ :param str resource: Custom resource to be used in this message
+ (Defaults to parent main_resource)
+ :return: New empty message
+ :rtype: Message
"""
return Message(parent=self, main_resource=resource, is_draft=True)
def mailbox(self, resource=None):
- """
- Creates MailBox Folder instance
- :param resource: Custom resource to be used in this mailbox. Defaults to parent main_resource.
+ """ Get an instance to the mailbox for the specified account resource
+
+ :param str resource: Custom resource to be used in this mailbox
+ (Defaults to parent main_resource)
+ :return: a representation of account mailbox
+ :rtype: MailBox
"""
return MailBox(parent=self, main_resource=resource, name='MailBox')
def address_book(self, *, resource=None, address_book='personal'):
- """
- Creates Address Book instance
- :param resource: Custom resource to be used in this address book. Defaults to parent main_resource.
- :param address_book: Choose from Personal or Gal (Global Address List)
- """
- if address_book == 'personal':
- return AddressBook(parent=self, main_resource=resource, name='Personal Address Book')
- elif address_book == 'gal':
+ """ Get an instance to the specified address book for the
+ specified account resource
+
+ :param str resource: Custom resource to be used in this address book
+ (Defaults to parent main_resource)
+ :param str address_book: Choose from 'Personal' or
+ 'GAL' (Global Address List)
+ :return: a representation of the specified address book
+ :rtype: AddressBook or GlobalAddressList
+ :raises RuntimeError: if invalid address_book is specified
+ """
+ if address_book.lower() == 'personal':
+ return AddressBook(parent=self, main_resource=resource,
+ name='Personal Address Book')
+ elif address_book.lower() == 'gal':
return GlobalAddressList(parent=self)
else:
- raise RuntimeError('Addres_book must be either "personal" (resource address book) or "gal" (Global Address List)')
+ raise RuntimeError(
+ 'address_book must be either "personal" '
+ '(resource address book) or "gal" (Global Address List)')
def schedule(self, *, resource=None):
- """
- Creates Schedule instance to handle calendars
- :param resource: Custom resource to be used in this schedule object. Defaults to parent main_resource.
+ """ Get an instance to work with calendar events for the
+ specified account resource
+
+ :param str resource: Custom resource to be used in this schedule object
+ (Defaults to parent main_resource)
+ :return: a representation of calendar events
+ :rtype: Schedule
"""
return Schedule(parent=self, main_resource=resource)
def storage(self, *, resource=None):
- """
- Creates a Storage instance to handle file storage like OneDrive or Sharepoint document libraries
- :param resource: Custom resource to be used in this drive object. Defaults to parent main_resource.
+ """ Get an instance to handle file storage like OneDrive or
+ Sharepoint document libraries for the specified account resource
+
+ :param str resource: Custom resource to be used in this drive object
+ (Defaults to parent main_resource)
+ :return: a representation of File Storage
+ :rtype: Storage
+ :raises RuntimeError: if protocol doesn't support the feature
"""
if not isinstance(self.protocol, MSGraphProtocol):
- # TODO: a custom protocol accessing OneDrive or Sharepoint Api will fail here.
- raise RuntimeError('Drive api only works on Microsoft Graph API')
+ # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here
+ raise RuntimeError(
+ 'Drive options only works on Microsoft Graph API')
return Storage(parent=self, main_resource=resource)
diff --git a/O365/address_book.py b/O365/address_book.py
index adad7eb65ae6..df801cadf197 100644
--- a/O365/address_book.py
+++ b/O365/address_book.py
@@ -3,7 +3,8 @@
from enum import Enum
from O365.message import HandleRecipientsMixin, Recipients, Message
-from O365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, AttachableMixin
+from O365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent
+from O365.utils import AttachableMixin
GAL_MAIN_RESOURCE = 'users'
@@ -17,28 +18,51 @@ class RecipientType(Enum):
class Contact(ApiComponent, AttachableMixin, HandleRecipientsMixin):
- """ Contact manages lists of events on an associated contact on office365. """
-
- _mapping = {'display_name': 'displayName', 'name': 'givenName', 'surname': 'surname', 'title': 'title', 'job_title': 'jobTitle',
- 'company_name': 'companyName', 'department': 'department', 'office_location': 'officeLocation',
- 'business_phones': 'businessPhones', 'mobile_phone': 'mobilePhone', 'home_phones': 'homePhones',
- 'emails': 'emailAddresses', 'business_addresses': 'businessAddress', 'home_addresses': 'homesAddress',
- 'other_addresses': 'otherAddress', 'categories': 'categories'}
+ """ Contact manages lists of events on associated contact on office365. """
+
+ _mapping = {
+ 'display_name': 'displayName',
+ 'name': 'givenName',
+ 'surname': 'surname',
+ 'title': 'title',
+ 'job_title': 'jobTitle',
+ 'company_name': 'companyName',
+ 'department': 'department',
+ 'office_location': 'officeLocation',
+ 'business_phones': 'businessPhones',
+ 'mobile_phone': 'mobilePhone',
+ 'home_phones': 'homePhones',
+ 'emails': 'emailAddresses',
+ 'business_addresses': 'businessAddress',
+ 'home_addresses': 'homesAddress',
+ 'other_addresses': 'otherAddress',
+ 'categories': 'categories'
+ }
_endpoints = {
'root_contact': '/contacts/{id}',
'child_contact': '/contactFolders/{id}/contacts'
}
+
message_constructor = Message
def __init__(self, *, parent=None, con=None, **kwargs):
+ """
+ :param parent:
+ :param con:
+ :param kwargs:
+ """
assert parent or con, 'Need a parent or a connection'
self.con = parent.con if parent else con
# Choose the main_resource passed in kwargs over the parent main_resource
- main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None
- super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource)
+ main_resource = kwargs.pop('main_resource', None) or getattr(parent,
+ 'main_resource',
+ None) if parent else None
+ super().__init__(
+ protocol=parent.protocol if parent else kwargs.get('protocol'),
+ main_resource=main_resource)
cloud_data = kwargs.get(self._cloud_data_key, {})
cc = self._cc # alias to shorten the code
@@ -48,8 +72,10 @@ def __init__(self, *, parent=None, con=None, **kwargs):
self.modified = cloud_data.get(cc('lastModifiedDateTime'), None)
local_tz = self.protocol.timezone
- self.created = parse(self.created).astimezone(local_tz) if self.created else None
- self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None
+ self.created = parse(self.created).astimezone(
+ local_tz) if self.created else None
+ self.modified = parse(self.modified).astimezone(
+ local_tz) if self.modified else None
self.display_name = cloud_data.get(cc('displayName'), '')
self.name = cloud_data.get(cc('givenName'), '')
@@ -63,7 +89,8 @@ def __init__(self, *, parent=None, con=None, **kwargs):
self.business_phones = cloud_data.get(cc('businessPhones'), []) or []
self.mobile_phone = cloud_data.get(cc('mobilePhone'), '')
self.home_phones = cloud_data.get(cc('homePhones'), []) or []
- self.__emails = self._recipients_from_cloud(cloud_data.get(cc('emailAddresses'), []))
+ self.__emails = self._recipients_from_cloud(
+ cloud_data.get(cc('emailAddresses'), []))
email = cloud_data.get(cc('email'))
if email and email not in self.__emails:
# a Contact from OneDrive?
@@ -134,7 +161,8 @@ def delete(self):
if not self.object_id:
raise RuntimeError('Attemping to delete an usaved Contact')
- url = self.build_url(self._endpoints.get('contact').format(id=self.object_id))
+ url = self.build_url(
+ self._endpoints.get('contact').format(id=self.object_id))
response = self.con.delete(url)
@@ -155,14 +183,18 @@ def update(self, fields):
for field in fields:
mapping = self._mapping.get(field)
if mapping is None:
- raise ValueError('{} is not a valid updatable field from Contact'.format(field))
+ raise ValueError(
+ '{} is not a valid updatable field from Contact'.format(
+ field))
update_value = getattr(self, field)
if isinstance(update_value, Recipients):
- data[self._cc(mapping)] = [self._recipient_to_cloud(recipient) for recipient in update_value]
+ data[self._cc(mapping)] = [self._recipient_to_cloud(recipient)
+ for recipient in update_value]
else:
data[self._cc(mapping)] = update_value
- url = self.build_url(self._endpoints.get('contact'.format(id=self.object_id)))
+ url = self.build_url(
+ self._endpoints.get('contact'.format(id=self.object_id)))
response = self.con.patch(url, data=data)
@@ -171,10 +203,12 @@ def update(self, fields):
def save(self):
""" Saves this Contact to the cloud """
if self.object_id:
- raise RuntimeError("Can't save an existing Contact. Use Update instead. ")
+ raise RuntimeError(
+ "Can't save an existing Contact. Use Update instead. ")
if self.folder_id:
- url = self.build_url(self._endpoints.get('child_contact').format(self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('child_contact').format(self.folder_id))
else:
url = self.build_url(self._endpoints.get('root_contact'))
@@ -189,8 +223,10 @@ def save(self):
self.modified = contact.get(self._cc('lastModifiedDateTime'), None)
local_tz = self.protocol.timezone
- self.created = parse(self.created).astimezone(local_tz) if self.created else None
- self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None
+ self.created = parse(self.created).astimezone(
+ local_tz) if self.created else None
+ self.modified = parse(self.modified).astimezone(
+ local_tz) if self.modified else None
return True
@@ -203,7 +239,8 @@ def new_message(self, recipient=None, *, recipient_type=RecipientType.TO):
"""
if self.main_resource == GAL_MAIN_RESOURCE:
# preventing the contact lookup to explode for big organizations..
- raise RuntimeError('Sending a message to all users within an Organization is not allowed')
+ raise RuntimeError(
+ 'Sending a message to all users within an Organization is not allowed')
if isinstance(recipient_type, str):
recipient_type = RecipientType(recipient_type)
@@ -240,14 +277,20 @@ def __init__(self, *, parent=None, con=None, **kwargs):
self.con = parent.con if parent else con
# Choose the main_resource passed in kwargs over the parent main_resource
- main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None
- super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource)
+ main_resource = kwargs.pop('main_resource', None) or getattr(parent,
+ 'main_resource',
+ None) if parent else None
+ super().__init__(
+ protocol=parent.protocol if parent else kwargs.get('protocol'),
+ main_resource=main_resource)
- self.root = kwargs.pop('root', False) # This folder has no parents if root = True.
+ self.root = kwargs.pop('root',
+ False) # This folder has no parents if root = True.
cloud_data = kwargs.get(self._cloud_data_key, {})
- self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', None)) # Fallback to manual folder
+ self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name',
+ None)) # Fallback to manual folder
self.folder_id = cloud_data.get(self._cc('id'), None)
self.parent_id = cloud_data.get(self._cc('parentFolderId'), None)
@@ -283,7 +326,9 @@ def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None):
if self.root:
url = self.build_url(self._endpoints.get('root_contacts'))
else:
- url = self.build_url(self._endpoints.get('folder_contacts').format(self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('folder_contacts').format(
+ self.folder_id))
if limit is None or limit > self.protocol.max_top_value:
batch = self.protocol.max_top_value
@@ -306,13 +351,15 @@ def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None):
data = response.json()
# Everything received from the cloud must be passed with self._cloud_data_key
- contacts = [self.contact_constructor(parent=self, **{self._cloud_data_key: contact})
+ contacts = [self.contact_constructor(parent=self,
+ **{self._cloud_data_key: contact})
for contact in data.get('value', [])]
next_link = data.get(NEXT_LINK_KEYWORD, None)
if batch and next_link:
- return Pagination(parent=self, data=contacts, constructor=self.contact_constructor,
+ return Pagination(parent=self, data=contacts,
+ constructor=self.contact_constructor,
next_link=next_link, limit=limit)
else:
return contacts
@@ -336,16 +383,20 @@ def get_folder(self, folder_id=None, folder_name=None):
if folder_id:
# get folder by it's id, independent of the parent of this folder_id
- url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id))
+ url = self.build_url(
+ self._endpoints.get('get_folder').format(id=folder_id))
params = None
else:
# get folder by name. Only looks up in child folders.
if self.root:
url = self.build_url(self._endpoints.get('root_folders'))
else:
- url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('child_folders').format(
+ id=self.folder_id))
- params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1}
+ params = {'$filter': "{} eq '{}'".format(self._cc('displayName'),
+ folder_name), '$top': 1}
response = self.con.get(url, params=params)
if not response:
@@ -361,7 +412,9 @@ def get_folder(self, folder_id=None, folder_name=None):
# Everything received from the cloud must be passed with self._cloud_data_key
# we don't pass parent, as this folder may not be a child of self.
- return ContactFolder(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder})
+ return ContactFolder(con=self.con, protocol=self.protocol,
+ main_resource=self.main_resource,
+ **{self._cloud_data_key: folder})
def get_folders(self, limit=None, *, query=None, order_by=None):
"""
@@ -374,7 +427,8 @@ def get_folders(self, limit=None, *, query=None, order_by=None):
if self.root:
url = self.build_url(self._endpoints.get('root_folders'))
else:
- url = self.build_url(self._endpoints.get('child_folders').format(self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('child_folders').format(self.folder_id))
params = {}
@@ -411,9 +465,11 @@ def create_child_folder(self, folder_name):
if self.root:
url = self.build_url(self._endpoints.get('root_folders'))
else:
- url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('child_folders').format(id=self.folder_id))
- response = self.con.post(url, data={self._cc('displayName'): folder_name})
+ response = self.con.post(url,
+ data={self._cc('displayName'): folder_name})
if not response:
return None
@@ -429,7 +485,8 @@ def update_folder_name(self, name):
if not name:
return False
- url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('get_folder').format(id=self.folder_id))
response = self.con.patch(url, data={self._cc('displayName'): name})
if not response:
@@ -452,7 +509,8 @@ def move_folder(self, to_folder):
if not to_folder:
return False
- url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('get_folder').format(id=self.folder_id))
if isinstance(to_folder, ContactFolder):
folder_id = to_folder.folder_id
@@ -461,7 +519,8 @@ def move_folder(self, to_folder):
else:
return False
- response = self.con.patch(url, data={self._cc('parentFolderId'): folder_id})
+ response = self.con.patch(url,
+ data={self._cc('parentFolderId'): folder_id})
if not response:
return False
@@ -478,7 +537,8 @@ def delete(self):
if self.root or not self.folder_id:
return False
- url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id))
+ url = self.build_url(
+ self._endpoints.get('get_folder').format(id=self.folder_id))
response = self.con.delete(url)
if not response:
@@ -537,7 +597,8 @@ class GlobalAddressList(BaseContactFolder):
def __init__(self, *, parent=None, con=None, **kwargs):
# set instance to be a root instance and the main_resource to be the GAL_MAIN_RESOURCE
- super().__init__(parent=parent, con=con, root=True, main_resource=GAL_MAIN_RESOURCE,
+ super().__init__(parent=parent, con=con, root=True,
+ main_resource=GAL_MAIN_RESOURCE,
name='Global Address List', **kwargs)
def __repr__(self):
@@ -560,4 +621,5 @@ def get_contact_by_email(self, email):
data = response.json()
# Everything received from the cloud must be passed with self._cloud_data_key
- return self.contact_constructor(parent=self, **{self._cloud_data_key: data})
+ return self.contact_constructor(parent=self,
+ **{self._cloud_data_key: data})
diff --git a/O365/calendar.py b/O365/calendar.py
index 9d8d6a6753a7..3e5a745bd899 100644
--- a/O365/calendar.py
+++ b/O365/calendar.py
@@ -9,6 +9,7 @@
from O365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, BaseAttachments, BaseAttachment, \
AttachableMixin, ImportanceLevel, TrackerSet
from O365.message import HandleRecipientsMixin
+from O365.utils.windows_tz import get_iana_tz, get_windows_tz
log = logging.getLogger(__name__)
@@ -100,7 +101,7 @@ def __init__(self, event, recurrence=None):
self.__ocurrences = recurrence_range.get(self._cc('numberOfOccurrences'), None)
self.__start_date = recurrence_range.get(self._cc('startDate'), None)
self.__end_date = recurrence_range.get(self._cc('endDate'), None)
- self.__recurrence_time_zone = recurrence_range.get(self._cc('recurrenceTimeZone'), self.protocol.get_windows_tz())
+ self.__recurrence_time_zone = recurrence_range.get(self._cc('recurrenceTimeZone'), get_windows_tz(self.protocol.timezone))
# time and time zones are not considered in recurrence ranges...
# I don't know why 'recurrenceTimeZone' is present here
# Sending a startDate datetime to the server results in an Error:
@@ -583,7 +584,7 @@ def __init__(self, *, parent=None, con=None, **kwargs):
start_obj = cloud_data.get(cc('start'), {})
if isinstance(start_obj, dict):
try:
- timezone = pytz.timezone(self.protocol.get_iana_tz(start_obj.get(self._cc('timeZone'), 'UTC')))
+ timezone = pytz.timezone(get_iana_tz(start_obj.get(self._cc('timeZone'), 'UTC')))
except pytz.UnknownTimeZoneError:
timezone = local_tz
start = start_obj.get(cc('dateTime'), None)
@@ -598,7 +599,7 @@ def __init__(self, *, parent=None, con=None, **kwargs):
end_obj = cloud_data.get(cc('end'), {})
if isinstance(end_obj, dict):
try:
- timezone = pytz.timezone(self.protocol.get_iana_tz(end_obj.get(self._cc('timeZone'), 'UTC')))
+ timezone = pytz.timezone(get_iana_tz(end_obj.get(self._cc('timeZone'), 'UTC')))
except pytz.UnknownTimeZoneError:
timezone = local_tz
end = end_obj.get(cc('dateTime'), None)
@@ -653,11 +654,11 @@ def to_api_data(self, restrict_keys=None):
cc('content'): self.__body},
cc('start'): {
cc('dateTime'): self.__start.strftime('%Y-%m-%dT%H:%M:%S'),
- cc('timeZone'): self.protocol.get_windows_tz(self.__start.tzinfo.zone)
+ cc('timeZone'): get_windows_tz(self.__start.tzinfo.zone or self.protocol.timezone)
},
cc('end'): {
cc('dateTime'): self.__end.strftime('%Y-%m-%dT%H:%M:%S'),
- cc('timeZone'): self.protocol.get_windows_tz(self.__end.tzinfo.zone)
+ cc('timeZone'): get_windows_tz(self.__end.tzinfo.zone or self.protocol.timezone)
},
cc('attendees'): self.__attendees.to_api_data(),
cc('location'): {cc('displayName'): self.__location},
diff --git a/O365/connection.py b/O365/connection.py
index abfaf30679fb..0c01730d836c 100644
--- a/O365/connection.py
+++ b/O365/connection.py
@@ -1,34 +1,38 @@
-import logging
import json
+import logging
import os
import time
from pathlib import Path
-from tzlocal import get_localzone
-from datetime import tzinfo
-import pytz
-from stringcase import pascalcase, camelcase, snakecase
+from oauthlib.oauth2 import TokenExpiredError
from requests import Session
from requests.adapters import HTTPAdapter
-from requests.packages.urllib3.util.retry import Retry # dynamic loading of module Retry by requests.packages
-from requests.exceptions import HTTPError, RequestException, ProxyError, SSLError, Timeout, ConnectionError
-from oauthlib.oauth2 import TokenExpiredError
+from requests.exceptions import HTTPError, RequestException, ProxyError
+from requests.exceptions import SSLError, Timeout, ConnectionError
+# Dynamic loading of module Retry by requests.packages
+# noinspection PyUnresolvedReferences
+from requests.packages.urllib3.util.retry import Retry
from requests_oauthlib import OAuth2Session
+from stringcase import pascalcase, camelcase, snakecase
+from tzlocal import get_localzone
-from O365.utils import ME_RESOURCE, IANA_TO_WIN, WIN_TO_IANA
+from O365.utils import ME_RESOURCE
log = logging.getLogger(__name__)
-O365_API_VERSION = 'v2.0' # v2.0 does not allow basic auth
+O365_API_VERSION = 'v2.0'
GRAPH_API_VERSION = 'v1.0'
OAUTH_REDIRECT_URL = 'https://outlook.office365.com/owa/'
-RETRIES_STATUS_LIST = (429, 500, 502, 503, 504) # 429 is the TooManyRequests status code.
+RETRIES_STATUS_LIST = (
+ 429, # Status code for TooManyRequests
+ 500, 502, 503, 504
+)
RETRIES_BACKOFF_FACTOR = 0.5
-
DEFAULT_SCOPES = {
- 'basic': [('offline_access',), 'User.Read'], # wrap any scope in a 1 element tuple to avoid prefixing
+ # wrap any scope in a 1 element tuple to avoid prefixing
+ 'basic': [('offline_access',), 'User.Read'],
'mailbox': ['Mail.Read'],
'mailbox_shared': ['Mail.Read.Shared'],
'message_send': ['Mail.Send'],
@@ -49,60 +53,92 @@
class Protocol:
""" Base class for all protocols """
- _protocol_url = 'not_defined' # Main url to request. Override in subclass
- _oauth_scope_prefix = '' # prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE)
- _oauth_scopes = {} # dictionary of {scopes_name: [scope1, scope2]}
-
- def __init__(self, *, protocol_url=None, api_version=None, default_resource=ME_RESOURCE,
- casing_function=None, protocol_scope_prefix=None, timezone=None, **kwargs):
- """
- :param protocol_url: the base url used to comunicate with the server
- :param api_version: the api version
- :param default_resource: the default resource to use when there's no other option
- :param casing_function: the casing transform function to be used on api keywords
- :param protocol_scope_prefix: prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE)
- :param timezone: prefered timezone, defaults to the system timezone
+ # Override these in subclass
+ _protocol_url = 'not_defined' # Main url to request.
+ _oauth_scope_prefix = '' # Prefix for scopes
+ _oauth_scopes = {} # Dictionary of {scopes_name: [scope1, scope2]}
+
+ def __init__(self, *, protocol_url=None, api_version=None,
+ default_resource=ME_RESOURCE,
+ casing_function=None, protocol_scope_prefix=None,
+ timezone=None, **kwargs):
+ """ Create a new protocol object
+
+ :param str protocol_url: the base url used to communicate with the
+ server
+ :param str api_version: the api version
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ :param function casing_function: the casing transform function to be
+ used on api keywords (camelcase / pascalcase)
+ :param str protocol_scope_prefix: prefix url for scopes
+ :param pytz.UTC timezone: preferred timezone, defaults to the
+ system timezone
+ :raises ValueError: if protocol_url or api_version are not supplied
"""
if protocol_url is None or api_version is None:
- raise ValueError('Must provide valid protocol_url and api_version values')
+ raise ValueError(
+ 'Must provide valid protocol_url and api_version values')
self.protocol_url = protocol_url or self._protocol_url
self.protocol_scope_prefix = protocol_scope_prefix or ''
self.api_version = api_version
self.service_url = '{}{}/'.format(protocol_url, api_version)
self.default_resource = default_resource
- self.use_default_casing = True if casing_function is None else False # if true just returns the key without transform
+ self.use_default_casing = True if casing_function is None else False
self.casing_function = casing_function or camelcase
self.timezone = timezone or get_localzone() # pytz timezone
self.max_top_value = 500 # Max $top parameter value
# define any keyword that can be different in this protocol
+ # TODO Not used anywhere, is this required/planned to use?
self.keyword_data_store = {}
+ # TODO Not used anywhere, is this required/planned to use?
def get_service_keyword(self, keyword):
- """ Returns the data set to the key in the internal data-key dict """
+ """ Returns the data set to the key in the internal data-key dict
+
+ :param str keyword: key to get value for
+ :return: value of the keyword
+ """
return self.keyword_data_store.get(keyword, None)
- def convert_case(self, dict_key):
+ def convert_case(self, key):
""" Returns a key converted with this protocol casing method
Converts case to send/read from the cloud
- When using Microsoft Graph API, the keywords of the API use lowerCamelCase Casing.
- When using Office 365 API, the keywords of the API use PascalCase Casing.
- Default case in this API is lowerCamelCase.
+ When using Microsoft Graph API, the keywords of the API use
+ lowerCamelCase Casing
+
+ When using Office 365 API, the keywords of the API use PascalCase Casing
- :param dict_key: a dictionary key to convert
+ Default case in this API is lowerCamelCase
+
+ :param str key: a dictionary key to convert
+ :return: key after case conversion
+ :rtype: str
"""
- return dict_key if self.use_default_casing else self.casing_function(dict_key)
+ return key if self.use_default_casing else self.casing_function(key)
@staticmethod
- def to_api_case(dict_key):
- """ Converts keys to snake case """
- return snakecase(dict_key)
+ def to_api_case(key):
+ """ Converts key to snake_case
+
+ :param str key: key to convert into snake_case
+ :return: key after case conversion
+ :rtype: str
+ """
+ return snakecase(key)
def get_scopes_for(self, user_provided_scopes):
- """ Returns a list of scopes needed for each of the scope_helpers provided
+ """ Returns a list of scopes needed for each of the
+ scope_helpers provided, by adding the prefix to them if required
+
:param user_provided_scopes: a list of scopes or scope helpers
+ :type user_provided_scopes: list or tuple or str
+ :return: scopes with url prefix added
+ :rtype: list
+ :raises ValueError: if unexpected datatype of scopes are passed
"""
if user_provided_scopes is None:
# return all available scopes
@@ -111,7 +147,8 @@ def get_scopes_for(self, user_provided_scopes):
user_provided_scopes = [user_provided_scopes]
if not isinstance(user_provided_scopes, (list, tuple)):
- raise ValueError("'user_provided_scopes' must be a list or a tuple of strings")
+ raise ValueError(
+ "'user_provided_scopes' must be a list or a tuple of strings")
scopes = set()
for app_part in user_provided_scopes:
@@ -121,7 +158,7 @@ def get_scopes_for(self, user_provided_scopes):
return list(scopes)
def _prefix_scope(self, scope):
- """ Inserts the protocol scope prefix """
+ """ Inserts the protocol scope prefix if required"""
if self.protocol_scope_prefix:
if isinstance(scope, tuple):
return scope[0]
@@ -135,34 +172,6 @@ def _prefix_scope(self, scope):
else:
return scope
- @staticmethod
- def get_iana_tz(windows_tz):
- """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given windows TimeZone
- Note: Windows Timezones are SHIT!
- """
- timezone = WIN_TO_IANA.get(windows_tz)
- if timezone is None:
- # Nope, that didn't work. Try adding "Standard Time",
- # it seems to work a lot of times:
- timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time')
-
- # Return what we have.
- if timezone is None:
- raise pytz.UnknownTimeZoneError("Can't find Windows TimeZone " + windows_tz)
-
- return timezone
-
- def get_windows_tz(self, iana_tz=None):
- """ Returns a valid windows TimeZone from a given pytz TimeZone (Iana/Olson Timezones)
- Note: Windows Timezones are SHIT!... no ... really THEY ARE HOLY FUCKING SHIT!.
- """
- iana_tz = iana_tz or self.timezone
- timezone = IANA_TO_WIN.get(iana_tz.zone if isinstance(iana_tz, tzinfo) else iana_tz)
- if timezone is None:
- raise pytz.UnknownTimeZoneError("Can't find Iana TimeZone " + iana_tz.zone)
-
- return timezone
-
class MSGraphProtocol(Protocol):
""" A Microsoft Graph Protocol Implementation
@@ -173,14 +182,30 @@ class MSGraphProtocol(Protocol):
_oauth_scope_prefix = 'https://graph.microsoft.com/'
_oauth_scopes = DEFAULT_SCOPES
- def __init__(self, api_version='v1.0', default_resource=ME_RESOURCE, **kwargs):
- super().__init__(protocol_url=self._protocol_url, api_version=api_version,
- default_resource=default_resource, casing_function=camelcase,
- protocol_scope_prefix=self._oauth_scope_prefix, **kwargs)
+ def __init__(self, api_version='v1.0', default_resource=ME_RESOURCE,
+ **kwargs):
+ """ Create a new Microsoft Graph protocol object
+
+ _protocol_url = 'https://graph.microsoft.com/'
+
+ _oauth_scope_prefix = 'https://graph.microsoft.com/'
+
+ :param str api_version: api version to use
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ """
+ super().__init__(protocol_url=self._protocol_url,
+ api_version=api_version,
+ default_resource=default_resource,
+ casing_function=camelcase,
+ protocol_scope_prefix=self._oauth_scope_prefix,
+ **kwargs)
self.keyword_data_store['message_type'] = 'microsoft.graph.message'
- self.keyword_data_store['file_attachment_type'] = '#microsoft.graph.fileAttachment'
- self.keyword_data_store['item_attachment_type'] = '#microsoft.graph.itemAttachment'
+ self.keyword_data_store[
+ 'file_attachment_type'] = '#microsoft.graph.fileAttachment'
+ self.keyword_data_store[
+ 'item_attachment_type'] = '#microsoft.graph.itemAttachment'
self.max_top_value = 999 # Max $top parameter value
@@ -193,53 +218,87 @@ class MSOffice365Protocol(Protocol):
_oauth_scope_prefix = 'https://outlook.office.com/'
_oauth_scopes = DEFAULT_SCOPES
- def __init__(self, api_version='v2.0', default_resource=ME_RESOURCE, **kwargs):
- super().__init__(protocol_url=self._protocol_url, api_version=api_version,
- default_resource=default_resource, casing_function=pascalcase,
- protocol_scope_prefix=self._oauth_scope_prefix, **kwargs)
+ def __init__(self, api_version='v2.0', default_resource=ME_RESOURCE,
+ **kwargs):
+ """ Create a new Office 365 protocol object
+
+ _protocol_url = 'https://outlook.office.com/api/'
+
+ _oauth_scope_prefix = 'https://outlook.office.com/'
- self.keyword_data_store['message_type'] = 'Microsoft.OutlookServices.Message'
- self.keyword_data_store['file_attachment_type'] = '#Microsoft.OutlookServices.FileAttachment'
- self.keyword_data_store['item_attachment_type'] = '#Microsoft.OutlookServices.ItemAttachment'
+ :param str api_version: api version to use
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ """
+ super().__init__(protocol_url=self._protocol_url,
+ api_version=api_version,
+ default_resource=default_resource,
+ casing_function=pascalcase,
+ protocol_scope_prefix=self._oauth_scope_prefix,
+ **kwargs)
+
+ self.keyword_data_store[
+ 'message_type'] = 'Microsoft.OutlookServices.Message'
+ self.keyword_data_store[
+ 'file_attachment_type'] = '#Microsoft.OutlookServices.' \
+ 'FileAttachment'
+ self.keyword_data_store[
+ 'item_attachment_type'] = '#Microsoft.OutlookServices.' \
+ 'ItemAttachment'
self.max_top_value = 999 # Max $top parameter value
class Connection:
- """ Handles all comunication (requests) between the app and the server """
+ """ Handles all communication (requests) between the app and the server """
- _oauth2_authorize_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
- _oauth2_token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
+ _oauth2_authorize_url = 'https://login.microsoftonline.com/common/' \
+ 'oauth2/v2.0/authorize'
+ _oauth2_token_url = 'https://login.microsoftonline.com/common/' \
+ 'oauth2/v2.0/token'
_default_token_file = 'o365_token.txt'
_default_token_path = Path() / _default_token_file
_allowed_methods = ['get', 'post', 'put', 'patch', 'delete']
def __init__(self, credentials, *, scopes=None,
- proxy_server=None, proxy_port=8080, proxy_username=None, proxy_password=None,
- requests_delay=200, raise_http_errors=True, request_retries=3, token_file_name=None):
+ proxy_server=None, proxy_port=8080, proxy_username=None,
+ proxy_password=None,
+ requests_delay=200, raise_http_errors=True, request_retries=3,
+ token_file_name=None):
""" Creates an API connection object
- :param credentials: a tuple containing the credentials for this connection.
- This could be either (username, password) using basic authentication or (client_id, client_secret) using oauth.
- Generate client_id and client_secret in https://apps.dev.microsoft.com.
- :param scopes: oauth2: a list of scopes permissions to request access to
- :param proxy_server: the proxy server
- :param proxy_port: the proxy port, defaults to 8080
- :param proxy_username: the proxy username
- :param proxy_password: the proxy password
- :param requests_delay: number of miliseconds to wait between api calls
- The Api will respond with 429 Too many requests if more than 17 requests are made per second.
- Defaults to 200 miliseconds just in case more than 1 connection is making requests across multiple processes.
- :param raise_http_errors: If True Http 4xx and 5xx status codes will raise as exceptions
- :param request_retries: number of retries done when the server responds with 5xx error codes.
- :param token_file_name: custom token file name to be used when storing the token credentials.
+ :param tuple credentials: a tuple of (client_id, client_secret)
+
+ Generate client_id and client_secret in https://apps.dev.microsoft.com
+ :param list[str] scopes: list of scopes to request access to
+ :param str proxy_server: the proxy server
+ :param int proxy_port: the proxy port, defaults to 8080
+ :param str proxy_username: the proxy username
+ :param str proxy_password: the proxy password
+ :param int requests_delay: number of milliseconds to wait between api
+ calls.
+
+ The Api will respond with 429 Too many requests if more than
+ 17 requests are made per second. Defaults to 200 milliseconds
+ just in case more than 1 connection is making requests
+ across multiple processes.
+ :param bool raise_http_errors: If True Http 4xx and 5xx status codes
+ will raise as exceptions
+ :param int request_retries: number of retries done when the server
+ responds with 5xx error codes.
+ :param str token_file_name: custom token file name to be used when
+ storing the OAuth token credentials.
+ :raises ValueError: if credentials is not tuple of
+ (client_id, client_secret)
"""
- if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]):
+ if not isinstance(credentials, tuple) or len(credentials) != 2 or (
+ not credentials[0] and not credentials[1]):
raise ValueError('Provide valid auth credentials')
self.auth = credentials
self.scopes = scopes
self.store_token = True
- self.token_path = (Path() / token_file_name) if token_file_name else self._default_token_path
+ self.token_path = ((Path() / token_file_name) if token_file_name
+ else self._default_token_path)
self.token = None
self.session = None # requests Oauth2Session object
@@ -247,7 +306,7 @@ def __init__(self, credentials, *, scopes=None,
self.proxy = {}
self.set_proxy(proxy_server, proxy_port, proxy_username, proxy_password)
self.requests_delay = requests_delay or 0
- self.previous_request_at = None # store the time of the previous request
+ self.previous_request_at = None # store previous request time
self.raise_http_errors = raise_http_errors
self.request_retries = request_retries
@@ -255,19 +314,34 @@ def __init__(self, credentials, *, scopes=None,
self.naive_session.proxies = self.proxy
if self.request_retries:
- retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
- backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
+ retry = Retry(total=self.request_retries, read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
adapter = HTTPAdapter(max_retries=retry)
self.naive_session.mount('http://', adapter)
self.naive_session.mount('https://', adapter)
- def set_proxy(self, proxy_server, proxy_port, proxy_username, proxy_password):
- """ Sets a proxy on the Session """
+ def set_proxy(self, proxy_server, proxy_port, proxy_username,
+ proxy_password):
+ """ Sets a proxy on the Session
+
+ :param str proxy_server: the proxy server
+ :param int proxy_port: the proxy port, defaults to 8080
+ :param str proxy_username: the proxy username
+ :param str proxy_password: the proxy password
+ """
if proxy_server and proxy_port:
if proxy_username and proxy_password:
self.proxy = {
- "http": "http://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port),
- "https": "https://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port),
+ "http": "http://{}:{}@{}:{}".format(proxy_username,
+ proxy_password,
+ proxy_server,
+ proxy_port),
+ "https": "https://{}:{}@{}:{}".format(proxy_username,
+ proxy_password,
+ proxy_server,
+ proxy_port),
}
else:
self.proxy = {
@@ -276,7 +350,11 @@ def set_proxy(self, proxy_server, proxy_port, proxy_username, proxy_password):
}
def check_token_file(self):
- """ Checks if the token file exists at the given position"""
+ """ Checks if the token file exists at the given position
+
+ :return: if file exists or not
+ :rtype: bool
+ """
if self.token_path:
path = Path(self.token_path)
else:
@@ -284,11 +362,15 @@ def check_token_file(self):
return path.exists()
- def get_authorization_url(self, requested_scopes=None, redirect_uri=OAUTH_REDIRECT_URL):
- """
- Inicialices the oauth authorization flow, getting the authorization url that the user must approve.
- This is a two step process, first call this function. Then get the url result from the user and then
- call 'request_token' to get and store the access token.
+ def get_authorization_url(self, requested_scopes=None,
+ redirect_uri=OAUTH_REDIRECT_URL):
+ """ Initializes the oauth authorization flow, getting the
+ authorization url that the user must approve.
+
+ :param list[str] requested_scopes: list of scopes to request access for
+ :param str redirect_uri: redirect url configured in registered app
+ :return: authorization url
+ :rtype: str
"""
client_id, client_secret = self.auth
@@ -300,48 +382,61 @@ def get_authorization_url(self, requested_scopes=None, redirect_uri=OAUTH_REDIRE
else:
raise ValueError('Must provide at least one scope')
- self.session = oauth = OAuth2Session(client_id=client_id, redirect_uri=redirect_uri, scope=scopes)
+ self.session = oauth = OAuth2Session(client_id=client_id,
+ redirect_uri=redirect_uri,
+ scope=scopes)
self.session.proxies = self.proxy
if self.request_retries:
- retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
- backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
+ retry = Retry(total=self.request_retries, read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
adapter = HTTPAdapter(max_retries=retry)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
- # TODO: access_type='offline' has no effect acording to documentation. This is done through scope 'offline_access'.
- auth_url, state = oauth.authorization_url(url=self._oauth2_authorize_url, access_type='offline')
+ # TODO: access_type='offline' has no effect ac cording to documentation
+ # TODO: This is done through scope 'offline_access'.
+ auth_url, state = oauth.authorization_url(
+ url=self._oauth2_authorize_url, access_type='offline')
return auth_url
- def request_token(self, authorizated_url, store_token=True, token_path=None):
- """
- Returns and saves the token with the authorizated_url provided by the user
-
- :param authorizated_url: url given by the authorization flow
- :param store_token: whether or not to store the token in file system,
- so u don't have to keep opening the auth link and authenticating every time
- :param token_path: full path to where the token should be saved to
+ def request_token(self, authorization_url, store_token=True,
+ token_path=None):
+ """ Authenticates for the specified url and gets the token, save the
+ token for future based if requested
+
+ :param str authorization_url: url given by the authorization flow
+ :param bool store_token: whether or not to store the token in file
+ system, so u don't have to keep opening the auth link and
+ authenticating every time
+ :param Path token_path: full path to where the token should be saved to
+ :return: Success/Failure
+ :rtype: bool
"""
if self.session is None:
- raise RuntimeError("Fist call 'get_authorization_url' to generate a valid oauth object")
+ raise RuntimeError("Fist call 'get_authorization_url' to "
+ "generate a valid oauth object")
client_id, client_secret = self.auth
- # Allow token scope to not match requested scope. (Other auth libraries allow
- # this, but Requests-OAuthlib raises exception on scope mismatch by default.)
+ # Allow token scope to not match requested scope.
+ # (Other auth libraries allow this, but Requests-OAuthlib
+ # raises exception on scope mismatch by default.)
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1'
try:
- self.token = self.session.fetch_token(token_url=self._oauth2_token_url,
- authorization_response=authorizated_url,
- client_id=client_id,
- client_secret=client_secret)
+ self.token = self.session.fetch_token(
+ token_url=self._oauth2_token_url,
+ authorization_response=authorization_url,
+ client_id=client_id,
+ client_secret=client_secret)
except Exception as e:
log.error('Unable to fetch auth token. Error: {}'.format(str(e)))
- return None
+ return False
if token_path:
self.token_path = token_path
@@ -354,21 +449,28 @@ def request_token(self, authorizated_url, store_token=True, token_path=None):
def get_session(self, token_path=None):
""" Create a requests Session object
- :param token_path: Only oauth: full path to where the token should be load from
+ :param Path token_path: (Only oauth) full path to where the token
+ should be load from
+ :return: A ready to use requests session
+ :rtype: OAuth2Session
"""
- self.token = self.token or self._load_token(token_path or self.token_path)
+ self.token = self.token or self._load_token(
+ token_path or self.token_path)
if self.token:
client_id, _ = self.auth
self.session = OAuth2Session(client_id=client_id, token=self.token)
else:
- raise RuntimeError('No auth token found. Authentication Flow needed')
+ raise RuntimeError(
+ 'No auth token found. Authentication Flow needed')
self.session.proxies = self.proxy
if self.request_retries:
- retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries,
- backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST)
+ retry = Retry(total=self.request_retries, read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
adapter = HTTPAdapter(max_retries=retry)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
@@ -376,44 +478,53 @@ def get_session(self, token_path=None):
return self.session
def refresh_token(self):
- """ Gets another token """
+ """ Refresh the OAuth authorization token """
client_id, client_secret = self.auth
- self.token = token = self.session.refresh_token(self._oauth2_token_url, client_id=client_id,
- client_secret=client_secret)
+ self.token = token = (self.session
+ .refresh_token(self._oauth2_token_url,
+ client_id=client_id,
+ client_secret=client_secret))
if self.store_token:
self._save_token(token)
def _check_delay(self):
""" Checks if a delay is needed between requests and sleeps if True """
if self.previous_request_at:
- dif = round(time.time() - self.previous_request_at, 2) * 1000 # difference in miliseconds
+ dif = round(time.time() - self.previous_request_at,
+ 2) * 1000 # difference in miliseconds
if dif < self.requests_delay:
- time.sleep((self.requests_delay - dif) / 1000) # sleep needs seconds
+ time.sleep(
+ (self.requests_delay - dif) / 1000) # sleep needs seconds
self.previous_request_at = time.time()
def _internal_request(self, request_obj, url, method, **kwargs):
- """
- Internal handling of requests. Handles Exceptions.
+ """ Internal handling of requests. Handles Exceptions.
:param request_obj: a requests session.
- :param url: the url to be requested
- :param method: the method used on the request
- :param kwargs: any other payload to be passed to requests
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
"""
method = method.lower()
- assert method in self._allowed_methods, 'Method must be one of the allowed ones'
+ assert method in self._allowed_methods, \
+ 'Method must be one of the allowed ones'
if method == 'get':
kwargs.setdefault('allow_redirects', True)
elif method in ['post', 'put', 'patch']:
if 'headers' not in kwargs:
kwargs['headers'] = {}
- if kwargs.get('headers') is not None and kwargs['headers'].get('Content-type') is None:
+ if kwargs.get('headers') is not None and kwargs['headers'].get(
+ 'Content-type') is None:
kwargs['headers']['Content-type'] = 'application/json'
- if 'data' in kwargs and kwargs['headers'].get('Content-type') == 'application/json':
- kwargs['data'] = json.dumps(kwargs['data']) # autoconvert to json
+ if 'data' in kwargs and kwargs['headers'].get(
+ 'Content-type') == 'application/json':
+ kwargs['data'] = json.dumps(
+ kwargs['data']) # auto convert to json
request_done = False
token_refreshed = False
@@ -423,15 +534,18 @@ def _internal_request(self, request_obj, url, method, **kwargs):
try:
log.info('Requesting ({}) URL: {}'.format(method.upper(), url))
log.info('Request parameters: {}'.format(kwargs))
- response = request_obj.request(method, url, **kwargs) # auto_retry will occur inside this funcion call if enabled
+ # auto_retry will occur inside this function call if enabled
+ response = request_obj.request(method, url,
+ **kwargs)
response.raise_for_status() # raise 4XX and 5XX error codes.
- log.info('Received response ({}) from URL {}'.format(response.status_code, response.url))
+ log.info('Received response ({}) from URL {}'.format(
+ response.status_code, response.url))
request_done = True
return response
except TokenExpiredError:
# Token has expired refresh token and try again on the next loop
if token_refreshed:
- # Refresh token done but still TolenExpiredError raise
+ # Refresh token done but still TokenExpiredError raise
raise RuntimeError('Token Refresh Operation not working')
log.info('Oauth Token is expired, fetching a new token')
self.refresh_token()
@@ -439,14 +553,17 @@ def _internal_request(self, request_obj, url, method, **kwargs):
token_refreshed = True
except (ConnectionError, ProxyError, SSLError, Timeout) as e:
# We couldn't connect to the target url, raise error
- log.debug('Connection Error calling: {}.{}'.format(url, 'Using proxy: {}'.format(self.proxy) if self.proxy else ''))
+ log.debug('Connection Error calling: {}.{}'
+ ''.format(url, ('Using proxy: {}'.format(self.proxy)
+ if self.proxy else '')))
raise e # re-raise exception
except HTTPError as e:
# Server response with 4XX or 5XX error status codes
status_code = int(e.response.status_code / 100)
if status_code == 4:
# Client Error
- log.error('Client Error: {}'.format(str(e))) # logged as error. Could be a library error or Api changes
+ # Logged as error. Could be a library error or Api changes
+ log.error('Client Error: {}'.format(str(e)))
else:
# Server Error
log.debug('Server Error: {}'.format(str(e)))
@@ -460,12 +577,26 @@ def _internal_request(self, request_obj, url, method, **kwargs):
raise e
def naive_request(self, url, method, **kwargs):
- """ A naive request without any Authorization headers """
+ """ Makes a request to url using an without oauth authorization
+ session, but through a normal session
+
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self._internal_request(self.naive_session, url, method, **kwargs)
def oauth_request(self, url, method, **kwargs):
- """ Makes a request to url using an oauth session """
+ """ Makes a request to url using an oauth session
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
# oauth authentication
if not self.session:
self.get_session()
@@ -473,30 +604,67 @@ def oauth_request(self, url, method, **kwargs):
return self._internal_request(self.session, url, method, **kwargs)
def get(self, url, params=None, **kwargs):
- """ Shorthand for self.request(url, 'get') """
+ """ Shorthand for self.oauth_request(url, 'get')
+
+ :param str url: url to send get oauth request to
+ :param dict params: request parameter to get the service data
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self.oauth_request(url, 'get', params=params, **kwargs)
def post(self, url, data=None, **kwargs):
- """ Shorthand for self.request(url, 'post') """
+ """ Shorthand for self.oauth_request(url, 'post')
+
+ :param str url: url to send post oauth request to
+ :param dict data: post data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self.oauth_request(url, 'post', data=data, **kwargs)
def put(self, url, data=None, **kwargs):
- """ Shorthand for self.request(url, 'put') """
+ """ Shorthand for self.oauth_request(url, 'put')
+
+ :param str url: url to send put oauth request to
+ :param dict data: put data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self.oauth_request(url, 'put', data=data, **kwargs)
def patch(self, url, data=None, **kwargs):
- """ Shorthand for self.request(url, 'patch') """
+ """ Shorthand for self.oauth_request(url, 'patch')
+
+ :param str url: url to send patch oauth request to
+ :param dict data: patch data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self.oauth_request(url, 'patch', data=data, **kwargs)
def delete(self, url, **kwargs):
- """ Shorthand for self.request(url, 'delete') """
+ """ Shorthand for self.request(url, 'delete')
+
+ :param str url: url to send delete oauth request to
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
return self.oauth_request(url, 'delete', **kwargs)
def _save_token(self, token, token_path=None):
""" Save the specified token dictionary to a specified file path
- :param token: token dictionary returned by the oauth token request
- :param token_path: Path object to where the file is to be saved
+ :param dict token: token dictionary returned by the oauth token request,
+ to be saved
+ :param Path token_path: Path to the file with token information saved
+ :return: Success/Failure
+ :rtype: bool
"""
if not token_path:
token_path = self.token_path or self._default_token_path
@@ -512,7 +680,9 @@ def _save_token(self, token, token_path=None):
def _load_token(self, token_path=None):
""" Load the specified token dictionary from specified file path
- :param token_path: Path object to the file with token information saved
+ :param Path token_path: Path to the file with token information saved
+ :return: token data
+ :rtype: dict
"""
if not token_path:
token_path = self.token_path or self._default_token_path
@@ -529,7 +699,9 @@ def _load_token(self, token_path=None):
def _delete_token(self, token_path=None):
""" Delete the specified token dictionary from specified file path
- :param token_path: Path object to where the token is saved
+ :param Path token_path: Path to the file with token information saved
+ :return: Success/Failure
+ :rtype: bool
"""
if not token_path:
token_path = self.token_path or self._default_token_path
@@ -543,21 +715,28 @@ def _delete_token(self, token_path=None):
return False
-def oauth_authentication_flow(client_id, client_secret, scopes=None, protocol=None, **kwargs):
- """
- A helper method to authenticate and get the oauth token
- :param client_id: the client_id
- :param client_secret: the client_secret
- :param scopes: a list of protocol user scopes to be converted by the protocol
- :param protocol: the protocol to be used. Defaults to MSGraphProtocol
+def oauth_authentication_flow(client_id, client_secret, scopes=None,
+ protocol=None, **kwargs):
+ """ A helper method to perform the OAuth2 authentication flow.
+ Authenticate and get the oauth token
+
+ :param str client_id: the client_id
+ :param str client_secret: the client_secret
+ :param list[str] scopes: a list of protocol user scopes to be converted
+ by the protocol
+ :param Protocol protocol: the protocol to be used.
+ Defaults to MSGraphProtocol
:param kwargs: other configuration to be passed to the Connection instance
+ :return: Success or Failure
+ :rtype: bool
"""
credentials = (client_id, client_secret)
protocol = protocol or MSGraphProtocol()
- con = Connection(credentials, scopes=protocol.get_scopes_for(scopes), **kwargs)
+ con = Connection(credentials, scopes=protocol.get_scopes_for(scopes),
+ **kwargs)
consent_url = con.get_authorization_url()
print('Visit the following url to give consent:')
@@ -568,7 +747,8 @@ def oauth_authentication_flow(client_id, client_secret, scopes=None, protocol=No
if token_url:
result = con.request_token(token_url)
if result:
- print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.')
+ print('Authentication Flow Completed. Oauth Access Token Stored. '
+ 'You can now use the API.')
else:
print('Something go wrong. Please try again.')
diff --git a/O365/utils/windows_tz.py b/O365/utils/windows_tz.py
index 537162537134..5a7b2081eccc 100644
--- a/O365/utils/windows_tz.py
+++ b/O365/utils/windows_tz.py
@@ -1,6 +1,9 @@
"""
Mapping from iana timezones to windows timezones and vice versa
"""
+from datetime import tzinfo
+
+import pytz
IANA_TO_WIN = {
'Africa/Abidjan': 'Greenwich Standard Time',
@@ -468,3 +471,39 @@
}
WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items()}
+
+
+def get_iana_tz(windows_tz):
+ """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given
+ windows TimeZone
+
+ :param windows_tz: windows format timezone usually returned by
+ microsoft api response
+ :return:
+ :rtype:
+ """
+ timezone = WIN_TO_IANA.get(windows_tz)
+ if timezone is None:
+ # Nope, that didn't work. Try adding "Standard Time",
+ # it seems to work a lot of times:
+ timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time')
+
+ # Return what we have.
+ if timezone is None:
+ raise pytz.UnknownTimeZoneError(
+ "Can't find Windows TimeZone " + windows_tz)
+
+ return timezone
+
+
+def get_windows_tz(iana_tz):
+ """ Returns a valid windows TimeZone from a given pytz TimeZone (Iana/Olson Timezones)
+ Note: Windows Timezones are SHIT!... no ... really THEY ARE HOLY FUCKING SHIT!.
+ """
+ timezone = IANA_TO_WIN.get(
+ iana_tz.zone if isinstance(iana_tz, tzinfo) else iana_tz)
+ if timezone is None:
+ raise pytz.UnknownTimeZoneError(
+ "Can't find Iana TimeZone " + iana_tz.zone)
+
+ return timezone
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 000000000000..67460c143d43
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,19 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SOURCEDIR = source
+BUILDDIR = latest
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 000000000000..51ab199ea55d
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/docs/latest/html/.buildinfo b/docs/latest/html/.buildinfo
new file mode 100644
index 000000000000..1bd7704827f0
--- /dev/null
+++ b/docs/latest/html/.buildinfo
@@ -0,0 +1,4 @@
+# Sphinx build info version 1
+# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
+config: 44afe70cbce334eb73de741fa1772f40
+tags: 645f666f9bcd5a90fca523b33c5a78b7
diff --git a/docs/latest/html/.nojekyll b/docs/latest/html/.nojekyll
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/docs/latest/html/_modules/O365/account.html b/docs/latest/html/_modules/O365/account.html
new file mode 100644
index 000000000000..5c368912bad9
--- /dev/null
+++ b/docs/latest/html/_modules/O365/account.html
@@ -0,0 +1,343 @@
+
+
+
+
+
+
+
+
+
+
+ O365.account — O365 documentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
[docs]def__init__(self,credentials,*,protocol=None,main_resource=ME_RESOURCE,
+ **kwargs):
+ """ Creates an object which is used to access resources related to the
+ specified credentials
+
+ :param tuple credentials: a tuple containing the client_id
+ and client_secret
+ :param Protocol protocol: the protocol to be used in this account
+ :param str main_resource: the resource to be used by this account
+ ('me' or 'users')
+ :param kwargs: any extra args to be passed to the Connection instance
+ :raises ValueError: if an invalid protocol is passed
+ """
+
+ protocol=protocolorMSGraphProtocol# Defaults to Graph protocol
+ self.protocol=protocol(default_resource=main_resource,
+ **kwargs)ifisinstance(protocol,
+ type)elseprotocol
+
+ ifnotisinstance(self.protocol,Protocol):
+ raiseValueError("'protocol' must be a subclass of Protocol")
+
+ self.con=Connection(credentials,**kwargs)
+ self.main_resource=main_resource
[docs]defauthenticate(self,*,scopes,**kwargs):
+ """ Performs the oauth authentication flow resulting in a stored token
+ It uses the credentials passed on instantiation
+
+ :param list[str] scopes: list of protocol user scopes to be converted
+ by the protocol
+ :param kwargs: other configurations to be passed to the
+ Connection instance
+ :return: Success / Failure
+ :rtype: bool
+ """
+ kwargs.setdefault('token_file_name',self.con.token_path.name)
+
+ returnoauth_authentication_flow(*self.con.auth,scopes=scopes,
+ protocol=self.protocol,**kwargs)
+
+ @property
+ defconnection(self):
+ """ Alias for self.con
+
+ :rtype: Connection
+ """
+ returnself.con
+
+
[docs]defnew_message(self,resource=None):
+ """ Creates a new message to be sent or stored
+
+ :param str resource: Custom resource to be used in this message
+ (Defaults to parent main_resource)
+ :return: New empty message
+ :rtype: Message
+ """
+ returnMessage(parent=self,main_resource=resource,is_draft=True)
+
+
[docs]defmailbox(self,resource=None):
+ """ Get an instance to the mailbox for the specified account resource
+
+ :param str resource: Custom resource to be used in this mailbox
+ (Defaults to parent main_resource)
+ :return: a representation of account mailbox
+ :rtype: MailBox
+ """
+ returnMailBox(parent=self,main_resource=resource,name='MailBox')
+
+
[docs]defaddress_book(self,*,resource=None,address_book='personal'):
+ """ Get an instance to the specified address book for the
+ specified account resource
+
+ :param str resource: Custom resource to be used in this address book
+ (Defaults to parent main_resource)
+ :param str address_book: Choose from 'Personal' or
+ 'GAL' (Global Address List)
+ :return: a representation of the specified address book
+ :rtype: AddressBook or GlobalAddressList
+ :raises RuntimeError: if invalid address_book is specified
+ """
+ ifaddress_book.lower()=='personal':
+ returnAddressBook(parent=self,main_resource=resource,
+ name='Personal Address Book')
+ elifaddress_book.lower()=='gal':
+ returnGlobalAddressList(parent=self)
+ else:
+ raiseRuntimeError(
+ 'address_book must be either "personal" '
+ '(resource address book) or "gal" (Global Address List)')
+
+
[docs]defschedule(self,*,resource=None):
+ """ Get an instance to work with calendar events for the
+ specified account resource
+
+ :param str resource: Custom resource to be used in this schedule object
+ (Defaults to parent main_resource)
+ :return: a representation of calendar events
+ :rtype: Schedule
+ """
+ returnSchedule(parent=self,main_resource=resource)
+
+
[docs]defstorage(self,*,resource=None):
+ """ Get an instance to handle file storage like OneDrive or
+ Sharepoint document libraries for the specified account resource
+
+ :param str resource: Custom resource to be used in this drive object
+ (Defaults to parent main_resource)
+ :return: a representation of File Storage
+ :rtype: Storage
+ :raises RuntimeError: if protocol doesn't support the feature
+ """
+ ifnotisinstance(self.protocol,MSGraphProtocol):
+ # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here
+ raiseRuntimeError(
+ 'Drive options only works on Microsoft Graph API')
+
+ returnStorage(parent=self,main_resource=resource)
[docs]defnew_message(self,recipient=None,*,recipient_type=RecipientType.TO):
+ """
+ This method returns a new draft Message instance with this contact first email as a recipient
+ :param recipient: a Recipient instance where to send this message. If None, first recipient with address.
+ :param recipient_type: a RecipientType Enum.
+ :return: a new draft Message or None if recipient has no addresses
+ """
+ ifself.main_resource==GAL_MAIN_RESOURCE:
+ # preventing the contact lookup to explode for big organizations..
+ raiseRuntimeError(
+ 'Sending a message to all users within an Organization is not allowed')
+
+ ifisinstance(recipient_type,str):
+ recipient_type=RecipientType(recipient_type)
+
+ recipient=recipientorself.emails.get_first_recipient_with_address()
+ ifnotrecipient:
+ returnNone
+
+ new_message=self.message_constructor(parent=self,is_draft=True)
+
+ target_recipients=getattr(new_message,str(recipient_type.value))
+ target_recipients.add(recipient)
+
+ returnnew_message
[docs]defget_contacts(self,limit=100,*,query=None,order_by=None,batch=None):
+ """
+ Gets a list of contacts from this address book
+
+ When quering the Global Address List the Users enpoint will be used.
+ Only a limited set of information will be available unless you have acces to
+ scope 'User.Read.All' wich requires App Administration Consent.
+ Also using the Users enpoint has some limitations on the quering capabilites.
+
+ To use query an order_by check the OData specification here:
+ http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html
+
+ :param limit: Number of elements to return. Over 999 uses batch.
+ :param query: a OData valid filter clause
+ :param order_by: OData valid order by clause
+ :param batch: Returns a custom iterator that retrieves items in batches allowing
+ to retrieve more items than the limit.
+ """
+
+ ifself.main_resource==GAL_MAIN_RESOURCE:
+ # using Users endpoint to access the Global Address List
+ url=self.build_url(self._endpoints.get('gal'))
+ else:
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_contacts'))
+ else:
+ url=self.build_url(
+ self._endpoints.get('folder_contacts').format(
+ self.folder_id))
+
+ iflimitisNoneorlimit>self.protocol.max_top_value:
+ batch=self.protocol.max_top_value
+
+ params={'$top':batchifbatchelselimit}
+
+ iforder_by:
+ params['$orderby']=order_by
+
+ ifquery:
+ ifisinstance(query,str):
+ params['$filter']=query
+ else:
+ params.update(query.as_params())
+
+ response=self.con.get(url,params=params)
+ ifnotresponse:
+ return[]
+
+ data=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ contacts=[self.contact_constructor(parent=self,
+ **{self._cloud_data_key:contact})
+ forcontactindata.get('value',[])]
+
+ next_link=data.get(NEXT_LINK_KEYWORD,None)
+
+ ifbatchandnext_link:
+ returnPagination(parent=self,data=contacts,
+ constructor=self.contact_constructor,
+ next_link=next_link,limit=limit)
+ else:
+ returncontacts
+
+
+
[docs]classContactFolder(BaseContactFolder):
+ """ A Contact Folder representation """
+
+
[docs]defget_folder(self,folder_id=None,folder_name=None):
+ """
+ Returns a ContactFolder by it's id or name
+ :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not)
+ :param folder_name: the folder name to be retrieved. Must be a child of this folder.
+ """
+
+ iffolder_idandfolder_name:
+ raiseRuntimeError('Provide only one of the options')
+
+ ifnotfolder_idandnotfolder_name:
+ raiseRuntimeError('Provide one of the options')
+
+ iffolder_id:
+ # get folder by it's id, independent of the parent of this folder_id
+ url=self.build_url(
+ self._endpoints.get('get_folder').format(id=folder_id))
+ params=None
+ else:
+ # get folder by name. Only looks up in child folders.
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(
+ self._endpoints.get('child_folders').format(
+ id=self.folder_id))
+
+ params={'$filter':"{} eq '{}'".format(self._cc('displayName'),
+ folder_name),'$top':1}
+
+ response=self.con.get(url,params=params)
+ ifnotresponse:
+ returnNone
+
+ iffolder_id:
+ folder=response.json()
+ else:
+ folder=response.json().get('value')
+ folder=folder[0]iffolderelseNone
+ iffolderisNone:
+ returnNone
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ # we don't pass parent, as this folder may not be a child of self.
+ returnContactFolder(con=self.con,protocol=self.protocol,
+ main_resource=self.main_resource,
+ **{self._cloud_data_key:folder})
+
+
[docs]defget_folders(self,limit=None,*,query=None,order_by=None):
+ """
+ Returns a list of child folders
+
+ :param limit: Number of elements to return.
+ :param query: a OData valid filter clause
+ :param order_by: OData valid order by clause
+ """
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(
+ self._endpoints.get('child_folders').format(self.folder_id))
+
+ params={}
+
+ iflimit:
+ params['$top']=limit
+
+ iforder_by:
+ params['$orderby']=order_by
+
+ ifquery:
+ ifisinstance(query,str):
+ params['$filter']=query
+ else:
+ params.update(query.as_params())
+
+ response=self.con.get(url,params=paramsorNone)
+ ifnotresponse:
+ return[]
+
+ data=response.json()
+
+ return[ContactFolder(parent=self,**{self._cloud_data_key:folder})
+ forfolderindata.get('value',[])]
+
+
[docs]defcreate_child_folder(self,folder_name):
+ """
+ Creates a new child folder
+ :return the new Folder Object or None
+ """
+
+ ifnotfolder_name:
+ returnNone
+
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(
+ self._endpoints.get('child_folders').format(id=self.folder_id))
+
+ response=self.con.post(url,
+ data={self._cc('displayName'):folder_name})
+ ifnotresponse:
+ returnNone
+
+ folder=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnContactFolder(parent=self,**{self._cloud_data_key:folder})
[docs]defnew_contact(self):
+ """ Creates a new contact to be saved into it's parent folder """
+ contact=self.contact_constructor(parent=self)
+ ifnotself.root:
+ contact.folder_id=self.folder_id
+
+ returncontact
+
+
[docs]defnew_message(self,recipient_type=RecipientType.TO,*,query=None):
+ """
+ This method returns a new draft Message instance with all the contacts first email as a recipient
+ :param recipient_type: a RecipientType Enum.
+ :param query: a query to filter the contacts (passed to get_contacts)
+ :return: a draft Message or None if no contacts could be retrieved
+ """
+
+ ifisinstance(recipient_type,str):
+ recipient_type=RecipientType(recipient_type)
+
+ recipients=[contact.emails[0]
+ forcontactinself.get_contacts(limit=None,query=query)
+ ifcontact.emailsandcontact.emails[0].address]
+
+ ifnotrecipients:
+ returnNone
+
+ new_message=self.message_constructor(parent=self,is_draft=True)
+ target_recipients=getattr(new_message,str(recipient_type.value))
+ target_recipients.add(recipients)
+
+ returnnew_message
+
+
+
[docs]classAddressBook(ContactFolder):
+ """ A class representing an address book """
+
+
[docs]def__init__(self,*,parent=None,con=None,**kwargs):
+ # set instance to be a root instance
+ super().__init__(parent=parent,con=con,root=True,**kwargs)
+
+ def__repr__(self):
+ return'Address Book resource: {}'.format(self.main_resource)
+
+
+
[docs]classGlobalAddressList(BaseContactFolder):
+ """ A class representing the Global Address List (Users API) """
+
+
[docs]def__init__(self,*,parent=None,con=None,**kwargs):
+ # set instance to be a root instance and the main_resource to be the GAL_MAIN_RESOURCE
+ super().__init__(parent=parent,con=con,root=True,
+ main_resource=GAL_MAIN_RESOURCE,
+ name='Global Address List',**kwargs)
[docs]classProtocol:
+ """ Base class for all protocols """
+
+ # Override these in subclass
+ _protocol_url='not_defined'# Main url to request.
+ _oauth_scope_prefix=''# Prefix for scopes
+ _oauth_scopes={}# Dictionary of {scopes_name: [scope1, scope2]}
+
+
[docs]def__init__(self,*,protocol_url=None,api_version=None,
+ default_resource=ME_RESOURCE,
+ casing_function=None,protocol_scope_prefix=None,
+ timezone=None,**kwargs):
+ """ Create a new protocol object
+
+ :param str protocol_url: the base url used to communicate with the
+ server
+ :param str api_version: the api version
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ :param function casing_function: the casing transform function to be
+ used on api keywords (camelcase / pascalcase)
+ :param str protocol_scope_prefix: prefix url for scopes
+ :param pytz.UTC timezone: preferred timezone, defaults to the
+ system timezone
+ :raises ValueError: if protocol_url or api_version are not supplied
+ """
+ ifprotocol_urlisNoneorapi_versionisNone:
+ raiseValueError(
+ 'Must provide valid protocol_url and api_version values')
+ self.protocol_url=protocol_urlorself._protocol_url
+ self.protocol_scope_prefix=protocol_scope_prefixor''
+ self.api_version=api_version
+ self.service_url='{}{}/'.format(protocol_url,api_version)
+ self.default_resource=default_resource
+ self.use_default_casing=Trueifcasing_functionisNoneelseFalse
+ self.casing_function=casing_functionorcamelcase
+ self.timezone=timezoneorget_localzone()# pytz timezone
+ self.max_top_value=500# Max $top parameter value
+
+ # define any keyword that can be different in this protocol
+ # TODO Not used anywhere, is this required/planned to use?
+ self.keyword_data_store={}
+
+ # TODO Not used anywhere, is this required/planned to use?
+
[docs]defget_service_keyword(self,keyword):
+ """ Returns the data set to the key in the internal data-key dict
+
+ :param str keyword: key to get value for
+ :return: value of the keyword
+ """
+ returnself.keyword_data_store.get(keyword,None)
+
+
[docs]defconvert_case(self,key):
+ """ Returns a key converted with this protocol casing method
+
+ Converts case to send/read from the cloud
+
+ When using Microsoft Graph API, the keywords of the API use
+ lowerCamelCase Casing
+
+ When using Office 365 API, the keywords of the API use PascalCase Casing
+
+ Default case in this API is lowerCamelCase
+
+ :param str key: a dictionary key to convert
+ :return: key after case conversion
+ :rtype: str
+ """
+ returnkeyifself.use_default_casingelseself.casing_function(key)
+
+
[docs]@staticmethod
+ defto_api_case(key):
+ """ Converts key to snake_case
+
+ :param str key: key to convert into snake_case
+ :return: key after case conversion
+ :rtype: str
+ """
+ returnsnakecase(key)
+
+
[docs]defget_scopes_for(self,user_provided_scopes):
+ """ Returns a list of scopes needed for each of the
+ scope_helpers provided, by adding the prefix to them if required
+
+ :param user_provided_scopes: a list of scopes or scope helpers
+ :type user_provided_scopes: list or tuple or str
+ :return: scopes with url prefix added
+ :rtype: list
+ :raises ValueError: if unexpected datatype of scopes are passed
+ """
+ ifuser_provided_scopesisNone:
+ # return all available scopes
+ user_provided_scopes=[app_partforapp_partinself._oauth_scopes]
+ elifisinstance(user_provided_scopes,str):
+ user_provided_scopes=[user_provided_scopes]
+
+ ifnotisinstance(user_provided_scopes,(list,tuple)):
+ raiseValueError(
+ "'user_provided_scopes' must be a list or a tuple of strings")
+
+ scopes=set()
+ forapp_partinuser_provided_scopes:
+ forscopeinself._oauth_scopes.get(app_part,[app_part]):
+ scopes.add(self._prefix_scope(scope))
+
+ returnlist(scopes)
[docs]def__init__(self,api_version='v1.0',default_resource=ME_RESOURCE,
+ **kwargs):
+ """ Create a new Microsoft Graph protocol object
+
+ _protocol_url = 'https://graph.microsoft.com/'
+
+ _oauth_scope_prefix = 'https://graph.microsoft.com/'
+
+ :param str api_version: api version to use
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ """
+ super().__init__(protocol_url=self._protocol_url,
+ api_version=api_version,
+ default_resource=default_resource,
+ casing_function=camelcase,
+ protocol_scope_prefix=self._oauth_scope_prefix,
+ **kwargs)
+
+ self.keyword_data_store['message_type']='microsoft.graph.message'
+ self.keyword_data_store[
+ 'file_attachment_type']='#microsoft.graph.fileAttachment'
+ self.keyword_data_store[
+ 'item_attachment_type']='#microsoft.graph.itemAttachment'
+ self.max_top_value=999# Max $top parameter value
[docs]def__init__(self,api_version='v2.0',default_resource=ME_RESOURCE,
+ **kwargs):
+ """ Create a new Office 365 protocol object
+
+ _protocol_url = 'https://outlook.office.com/api/'
+
+ _oauth_scope_prefix = 'https://outlook.office.com/'
+
+ :param str api_version: api version to use
+ :param str default_resource: the default resource to use when there is
+ nothing explicitly specified during the requests
+ """
+ super().__init__(protocol_url=self._protocol_url,
+ api_version=api_version,
+ default_resource=default_resource,
+ casing_function=pascalcase,
+ protocol_scope_prefix=self._oauth_scope_prefix,
+ **kwargs)
+
+ self.keyword_data_store[
+ 'message_type']='Microsoft.OutlookServices.Message'
+ self.keyword_data_store[
+ 'file_attachment_type']='#Microsoft.OutlookServices.' \
+ 'FileAttachment'
+ self.keyword_data_store[
+ 'item_attachment_type']='#Microsoft.OutlookServices.' \
+ 'ItemAttachment'
+ self.max_top_value=999# Max $top parameter value
+
+
+
[docs]classConnection:
+ """ Handles all communication (requests) between the app and the server """
+
+ _oauth2_authorize_url='https://login.microsoftonline.com/common/' \
+ 'oauth2/v2.0/authorize'
+ _oauth2_token_url='https://login.microsoftonline.com/common/' \
+ 'oauth2/v2.0/token'
+ _default_token_file='o365_token.txt'
+ _default_token_path=Path()/_default_token_file
+ _allowed_methods=['get','post','put','patch','delete']
+
+
[docs]def__init__(self,credentials,*,scopes=None,
+ proxy_server=None,proxy_port=8080,proxy_username=None,
+ proxy_password=None,
+ requests_delay=200,raise_http_errors=True,request_retries=3,
+ token_file_name=None):
+ """ Creates an API connection object
+
+ :param tuple credentials: a tuple of (client_id, client_secret)
+
+ Generate client_id and client_secret in https://apps.dev.microsoft.com
+ :param list[str] scopes: list of scopes to request access to
+ :param str proxy_server: the proxy server
+ :param int proxy_port: the proxy port, defaults to 8080
+ :param str proxy_username: the proxy username
+ :param str proxy_password: the proxy password
+ :param int requests_delay: number of milliseconds to wait between api
+ calls.
+
+ The Api will respond with 429 Too many requests if more than
+ 17 requests are made per second. Defaults to 200 milliseconds
+ just in case more than 1 connection is making requests
+ across multiple processes.
+ :param bool raise_http_errors: If True Http 4xx and 5xx status codes
+ will raise as exceptions
+ :param int request_retries: number of retries done when the server
+ responds with 5xx error codes.
+ :param str token_file_name: custom token file name to be used when
+ storing the OAuth token credentials.
+ :raises ValueError: if credentials is not tuple of
+ (client_id, client_secret)
+ """
+ ifnotisinstance(credentials,tuple)orlen(credentials)!=2or(
+ notcredentials[0]andnotcredentials[1]):
+ raiseValueError('Provide valid auth credentials')
+
+ self.auth=credentials
+ self.scopes=scopes
+ self.store_token=True
+ self.token_path=((Path()/token_file_name)iftoken_file_name
+ elseself._default_token_path)
+ self.token=None
+
+ self.session=None# requests Oauth2Session object
+
+ self.proxy={}
+ self.set_proxy(proxy_server,proxy_port,proxy_username,proxy_password)
+ self.requests_delay=requests_delayor0
+ self.previous_request_at=None# store previous request time
+ self.raise_http_errors=raise_http_errors
+ self.request_retries=request_retries
+
+ self.naive_session=Session()# requests Session object
+ self.naive_session.proxies=self.proxy
+
+ ifself.request_retries:
+ retry=Retry(total=self.request_retries,read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
+ adapter=HTTPAdapter(max_retries=retry)
+ self.naive_session.mount('http://',adapter)
+ self.naive_session.mount('https://',adapter)
+
+
[docs]defset_proxy(self,proxy_server,proxy_port,proxy_username,
+ proxy_password):
+ """ Sets a proxy on the Session
+
+ :param str proxy_server: the proxy server
+ :param int proxy_port: the proxy port, defaults to 8080
+ :param str proxy_username: the proxy username
+ :param str proxy_password: the proxy password
+ """
+ ifproxy_serverandproxy_port:
+ ifproxy_usernameandproxy_password:
+ self.proxy={
+ "http":"http://{}:{}@{}:{}".format(proxy_username,
+ proxy_password,
+ proxy_server,
+ proxy_port),
+ "https":"https://{}:{}@{}:{}".format(proxy_username,
+ proxy_password,
+ proxy_server,
+ proxy_port),
+ }
+ else:
+ self.proxy={
+ "http":"http://{}:{}".format(proxy_server,proxy_port),
+ "https":"https://{}:{}".format(proxy_server,proxy_port),
+ }
+
+
[docs]defcheck_token_file(self):
+ """ Checks if the token file exists at the given position
+
+ :return: if file exists or not
+ :rtype: bool
+ """
+ ifself.token_path:
+ path=Path(self.token_path)
+ else:
+ path=self._default_token_path
+
+ returnpath.exists()
+
+
[docs]defget_authorization_url(self,requested_scopes=None,
+ redirect_uri=OAUTH_REDIRECT_URL):
+ """ Initializes the oauth authorization flow, getting the
+ authorization url that the user must approve.
+
+ :param list[str] requested_scopes: list of scopes to request access for
+ :param str redirect_uri: redirect url configured in registered app
+ :return: authorization url
+ :rtype: str
+ """
+
+ client_id,client_secret=self.auth
+
+ ifrequested_scopes:
+ scopes=requested_scopes
+ elifself.scopesisnotNone:
+ scopes=self.scopes
+ else:
+ raiseValueError('Must provide at least one scope')
+
+ self.session=oauth=OAuth2Session(client_id=client_id,
+ redirect_uri=redirect_uri,
+ scope=scopes)
+ self.session.proxies=self.proxy
+ ifself.request_retries:
+ retry=Retry(total=self.request_retries,read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
+ adapter=HTTPAdapter(max_retries=retry)
+ self.session.mount('http://',adapter)
+ self.session.mount('https://',adapter)
+
+ # TODO: access_type='offline' has no effect ac cording to documentation
+ # TODO: This is done through scope 'offline_access'.
+ auth_url,state=oauth.authorization_url(
+ url=self._oauth2_authorize_url,access_type='offline')
+
+ returnauth_url
+
+
[docs]defrequest_token(self,authorization_url,store_token=True,
+ token_path=None):
+ """ Authenticates for the specified url and gets the token, save the
+ token for future based if requested
+
+ :param str authorization_url: url given by the authorization flow
+ :param bool store_token: whether or not to store the token in file
+ system, so u don't have to keep opening the auth link and
+ authenticating every time
+ :param Path token_path: full path to where the token should be saved to
+ :return: Success/Failure
+ :rtype: bool
+ """
+
+ ifself.sessionisNone:
+ raiseRuntimeError("Fist call 'get_authorization_url' to "
+ "generate a valid oauth object")
+
+ client_id,client_secret=self.auth
+
+ # Allow token scope to not match requested scope.
+ # (Other auth libraries allow this, but Requests-OAuthlib
+ # raises exception on scope mismatch by default.)
+ os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE']='1'
+ os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE']='1'
+
+ try:
+ self.token=self.session.fetch_token(
+ token_url=self._oauth2_token_url,
+ authorization_response=authorization_url,
+ client_id=client_id,
+ client_secret=client_secret)
+ exceptExceptionase:
+ log.error('Unable to fetch auth token. Error: {}'.format(str(e)))
+ returnFalse
+
+ iftoken_path:
+ self.token_path=token_path
+ self.store_token=store_token
+ ifself.store_token:
+ self._save_token(self.token,self.token_path)
+
+ returnTrue
+
+
[docs]defget_session(self,token_path=None):
+ """ Create a requests Session object
+
+ :param Path token_path: (Only oauth) full path to where the token
+ should be load from
+ :return: A ready to use requests session
+ :rtype: OAuth2Session
+ """
+ self.token=self.tokenorself._load_token(
+ token_pathorself.token_path)
+
+ ifself.token:
+ client_id,_=self.auth
+ self.session=OAuth2Session(client_id=client_id,token=self.token)
+ else:
+ raiseRuntimeError(
+ 'No auth token found. Authentication Flow needed')
+
+ self.session.proxies=self.proxy
+
+ ifself.request_retries:
+ retry=Retry(total=self.request_retries,read=self.request_retries,
+ connect=self.request_retries,
+ backoff_factor=RETRIES_BACKOFF_FACTOR,
+ status_forcelist=RETRIES_STATUS_LIST)
+ adapter=HTTPAdapter(max_retries=retry)
+ self.session.mount('http://',adapter)
+ self.session.mount('https://',adapter)
+
+ returnself.session
+
+ def_check_delay(self):
+ """ Checks if a delay is needed between requests and sleeps if True """
+ ifself.previous_request_at:
+ dif=round(time.time()-self.previous_request_at,
+ 2)*1000# difference in miliseconds
+ ifdif<self.requests_delay:
+ time.sleep(
+ (self.requests_delay-dif)/1000)# sleep needs seconds
+ self.previous_request_at=time.time()
+
+ def_internal_request(self,request_obj,url,method,**kwargs):
+ """ Internal handling of requests. Handles Exceptions.
+
+ :param request_obj: a requests session.
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+
+ method=method.lower()
+ assertmethodinself._allowed_methods, \
+ 'Method must be one of the allowed ones'
+
+ ifmethod=='get':
+ kwargs.setdefault('allow_redirects',True)
+ elifmethodin['post','put','patch']:
+ if'headers'notinkwargs:
+ kwargs['headers']={}
+ ifkwargs.get('headers')isnotNoneandkwargs['headers'].get(
+ 'Content-type')isNone:
+ kwargs['headers']['Content-type']='application/json'
+ if'data'inkwargsandkwargs['headers'].get(
+ 'Content-type')=='application/json':
+ kwargs['data']=json.dumps(
+ kwargs['data'])# auto convert to json
+
+ request_done=False
+ token_refreshed=False
+
+ whilenotrequest_done:
+ self._check_delay()# sleeps if needed
+ try:
+ log.info('Requesting ({}) URL: {}'.format(method.upper(),url))
+ log.info('Request parameters: {}'.format(kwargs))
+ # auto_retry will occur inside this function call if enabled
+ response=request_obj.request(method,url,
+ **kwargs)
+ response.raise_for_status()# raise 4XX and 5XX error codes.
+ log.info('Received response ({}) from URL {}'.format(
+ response.status_code,response.url))
+ request_done=True
+ returnresponse
+ exceptTokenExpiredError:
+ # Token has expired refresh token and try again on the next loop
+ iftoken_refreshed:
+ # Refresh token done but still TokenExpiredError raise
+ raiseRuntimeError('Token Refresh Operation not working')
+ log.info('Oauth Token is expired, fetching a new token')
+ self.refresh_token()
+ log.info('New oauth token fetched')
+ token_refreshed=True
+ except(ConnectionError,ProxyError,SSLError,Timeout)ase:
+ # We couldn't connect to the target url, raise error
+ log.debug('Connection Error calling: {}.{}'
+ ''.format(url,('Using proxy: {}'.format(self.proxy)
+ ifself.proxyelse'')))
+ raisee# re-raise exception
+ exceptHTTPErrorase:
+ # Server response with 4XX or 5XX error status codes
+ status_code=int(e.response.status_code/100)
+ ifstatus_code==4:
+ # Client Error
+ # Logged as error. Could be a library error or Api changes
+ log.error('Client Error: {}'.format(str(e)))
+ else:
+ # Server Error
+ log.debug('Server Error: {}'.format(str(e)))
+ ifself.raise_http_errors:
+ raisee
+ else:
+ returne.response
+ exceptRequestExceptionase:
+ # catch any other exception raised by requests
+ log.debug('Request Exception: {}'.format(str(e)))
+ raisee
+
+
[docs]defnaive_request(self,url,method,**kwargs):
+ """ Makes a request to url using an without oauth authorization
+ session, but through a normal session
+
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself._internal_request(self.naive_session,url,method,**kwargs)
+
+
[docs]defoauth_request(self,url,method,**kwargs):
+ """ Makes a request to url using an oauth session
+
+ :param str url: url to send request to
+ :param str method: type of request (get/put/post/patch/delete)
+ :param kwargs: extra params to send to the request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ # oauth authentication
+ ifnotself.session:
+ self.get_session()
+
+ returnself._internal_request(self.session,url,method,**kwargs)
+
+
[docs]defget(self,url,params=None,**kwargs):
+ """ Shorthand for self.oauth_request(url, 'get')
+
+ :param str url: url to send get oauth request to
+ :param dict params: request parameter to get the service data
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself.oauth_request(url,'get',params=params,**kwargs)
+
+
[docs]defpost(self,url,data=None,**kwargs):
+ """ Shorthand for self.oauth_request(url, 'post')
+
+ :param str url: url to send post oauth request to
+ :param dict data: post data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself.oauth_request(url,'post',data=data,**kwargs)
+
+
[docs]defput(self,url,data=None,**kwargs):
+ """ Shorthand for self.oauth_request(url, 'put')
+
+ :param str url: url to send put oauth request to
+ :param dict data: put data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself.oauth_request(url,'put',data=data,**kwargs)
+
+
[docs]defpatch(self,url,data=None,**kwargs):
+ """ Shorthand for self.oauth_request(url, 'patch')
+
+ :param str url: url to send patch oauth request to
+ :param dict data: patch data to update the service
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself.oauth_request(url,'patch',data=data,**kwargs)
+
+
[docs]defdelete(self,url,**kwargs):
+ """ Shorthand for self.request(url, 'delete')
+
+ :param str url: url to send delete oauth request to
+ :param kwargs: extra params to send to request api
+ :return: Response of the request
+ :rtype: requests.Response
+ """
+ returnself.oauth_request(url,'delete',**kwargs)
+
+ def_save_token(self,token,token_path=None):
+ """ Save the specified token dictionary to a specified file path
+
+ :param dict token: token dictionary returned by the oauth token request,
+ to be saved
+ :param Path token_path: Path to the file with token information saved
+ :return: Success/Failure
+ :rtype: bool
+ """
+ ifnottoken_path:
+ token_path=self.token_pathorself._default_token_path
+ else:
+ ifnotisinstance(token_path,Path):
+ raiseValueError('token_path must be a valid Path from pathlib')
+
+ withtoken_path.open('w')astoken_file:
+ json.dump(token,token_file,indent=True)
+
+ returnTrue
+
+ def_load_token(self,token_path=None):
+ """ Load the specified token dictionary from specified file path
+
+ :param Path token_path: Path to the file with token information saved
+ :return: token data
+ :rtype: dict
+ """
+ ifnottoken_path:
+ token_path=self.token_pathorself._default_token_path
+ else:
+ ifnotisinstance(token_path,Path):
+ raiseValueError('token_path must be a valid Path from pathlib')
+
+ token=None
+ iftoken_path.exists():
+ withtoken_path.open('r')astoken_file:
+ token=json.load(token_file)
+ returntoken
+
+ def_delete_token(self,token_path=None):
+ """ Delete the specified token dictionary from specified file path
+
+ :param Path token_path: Path to the file with token information saved
+ :return: Success/Failure
+ :rtype: bool
+ """
+ ifnottoken_path:
+ token_path=self.token_pathorself._default_token_path
+ else:
+ ifnotisinstance(token_path,Path):
+ raiseValueError('token_path must be a valid Path from pathlib')
+
+ iftoken_path.exists():
+ token_path.unlink()
+ returnTrue
+ returnFalse
+
+
+
[docs]defoauth_authentication_flow(client_id,client_secret,scopes=None,
+ protocol=None,**kwargs):
+ """ A helper method to perform the OAuth2 authentication flow.
+ Authenticate and get the oauth token
+
+ :param str client_id: the client_id
+ :param str client_secret: the client_secret
+ :param list[str] scopes: a list of protocol user scopes to be converted
+ by the protocol
+ :param Protocol protocol: the protocol to be used.
+ Defaults to MSGraphProtocol
+ :param kwargs: other configuration to be passed to the Connection instance
+ :return: Success or Failure
+ :rtype: bool
+ """
+
+ credentials=(client_id,client_secret)
+
+ protocol=protocolorMSGraphProtocol()
+
+ con=Connection(credentials,scopes=protocol.get_scopes_for(scopes),
+ **kwargs)
+
+ consent_url=con.get_authorization_url()
+ print('Visit the following url to give consent:')
+ print(consent_url)
+
+ token_url=input('Paste the authenticated url here: ')
+
+ iftoken_url:
+ result=con.request_token(token_url)
+ ifresult:
+ print('Authentication Flow Completed. Oauth Access Token Stored. '
+ 'You can now use the API.')
+ else:
+ print('Something go wrong. Please try again.')
+
+ returnbool(result)
+ else:
+ print('Authentication Flow aborted.')
+ returnFalse
[docs]defget_folders(self,limit=None,*,query=None,order_by=None,batch=None):
+ """
+ Returns a list of child folders
+
+ :param limit: limits the result set. Over 999 uses batch.
+ :param query: applies a filter to the request such as "displayName eq 'HelloFolder'"
+ :param order_by: orders the result set based on this condition
+ :param batch: Returns a custom iterator that retrieves items in batches allowing to retrieve more items than the limit.
+ """
+
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
+
+ iflimitisNoneorlimit>self.protocol.max_top_value:
+ batch=self.protocol.max_top_value
+
+ params={'$top':batchifbatchelselimit}
+
+ iforder_by:
+ params['$orderby']=order_by
+
+ ifquery:
+ ifisinstance(query,str):
+ params['$filter']=query
+ else:
+ params.update(query.as_params())
+
+ response=self.con.get(url,params=params)
+ ifnotresponse:
+ return[]
+
+ data=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ self_class=getattr(self,'folder_constructor',type(self))
+ folders=[self_class(parent=self,**{self._cloud_data_key:folder})forfolderindata.get('value',[])]
+ next_link=data.get(NEXT_LINK_KEYWORD,None)
+ ifbatchandnext_link:
+ returnPagination(parent=self,data=folders,constructor=self_class,
+ next_link=next_link,limit=limit)
+ else:
+ returnfolders
+
+
[docs]defget_message(self,query=None,*,download_attachments=False):
+ """ A shorcut to get_messages with limit=1 """
+ messages=self.get_messages(limit=1,query=query,download_attachments=download_attachments)
+
+ returnmessages[0]ifmessageselseNone
+
+
[docs]defget_messages(self,limit=25,*,query=None,order_by=None,batch=None,download_attachments=False):
+ """
+ Downloads messages from this folder
+
+ :param limit: limits the result set. Over 999 uses batch.
+ :param query: applies a filter to the request such as 'displayName:HelloFolder'
+ :param order_by: orders the result set based on this condition
+ :param batch: Returns a custom iterator that retrieves items in batches allowing
+ to retrieve more items than the limit. Download_attachments is ignored.
+ :param download_attachments: downloads message attachments
+ """
+
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_messages'))
+ else:
+ url=self.build_url(self._endpoints.get('folder_messages').format(id=self.folder_id))
+
+ iflimitisNoneorlimit>self.protocol.max_top_value:
+ batch=self.protocol.max_top_value
+
+ ifbatch:
+ download_attachments=False
+
+ params={'$top':batchifbatchelselimit}
+
+ iforder_by:
+ params['$orderby']=order_by
+
+ ifquery:
+ ifisinstance(query,str):
+ params['$filter']=query
+ else:
+ params.update(query.as_params())
+
+ response=self.con.get(url,params=params)
+ ifnotresponse:
+ return[]
+
+ data=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ messages=[self.message_constructor(parent=self,download_attachments=download_attachments,
+ **{self._cloud_data_key:message})
+ formessageindata.get('value',[])]
+
+ next_link=data.get(NEXT_LINK_KEYWORD,None)
+ ifbatchandnext_link:
+ returnPagination(parent=self,data=messages,constructor=self.message_constructor,
+ next_link=next_link,limit=limit)
+ else:
+ returnmessages
+
+
[docs]defcreate_child_folder(self,folder_name):
+ """
+ Creates a new child folder
+ :return the new Folder Object or None
+ """
+ ifnotfolder_name:
+ returnNone
+
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
+
+ response=self.con.post(url,data={self._cc('displayName'):folder_name})
+ ifnotresponse:
+ returnNone
+
+ folder=response.json()
+
+ self_class=getattr(self,'folder_constructor',type(self))
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnself_class(parent=self,**{self._cloud_data_key:folder})
+
+
[docs]defget_folder(self,*,folder_id=None,folder_name=None):
+ """
+ Returns a folder by it's id or name
+ :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not)
+ :param folder_name: the folder name to be retrieved. Must be a child of this folder.
+ """
+ iffolder_idandfolder_name:
+ raiseRuntimeError('Provide only one of the options')
+
+ ifnotfolder_idandnotfolder_name:
+ raiseRuntimeError('Provide one of the options')
+
+ iffolder_id:
+ # get folder by it's id, independent of the parent of this folder_id
+ url=self.build_url(self._endpoints.get('get_folder').format(id=folder_id))
+ params=None
+ else:
+ # get folder by name. Only looks up in child folders.
+ ifself.root:
+ url=self.build_url(self._endpoints.get('root_folders'))
+ else:
+ url=self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id))
+ params={'$filter':"{} eq '{}'".format(self._cc('displayName'),folder_name),'$top':1}
+
+ response=self.con.get(url,params=params)
+ ifnotresponse:
+ returnNone
+
+ iffolder_id:
+ folder=response.json()
+ else:
+ folder=response.json().get('value')
+ folder=folder[0]iffolderelseNone
+ iffolderisNone:
+ returnNone
+
+ self_class=getattr(self,'folder_constructor',type(self))
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ # we don't pass parent, as this folder may not be a child of self.
+ returnself_class(con=self.con,protocol=self.protocol,main_resource=self.main_resource,**{self._cloud_data_key:folder})
+
+
[docs]defrefresh_folder(self,update_parent_if_changed=False):
+ """
+ Re-donwload folder data
+ Inbox Folder will be unable to download its own data (no folder_id)
+ :param update_parent_if_changed: updates self.parent with the new parent Folder if changed
+ """
+ folder_id=getattr(self,'folder_id',None)
+ ifself.rootorfolder_idisNone:
+ returnFalse
+
+ folder=self.get_folder(folder_id=folder_id)
+ iffolderisNone:
+ returnFalse
+
+ self.name=folder.name
+ iffolder.parent_idandself.parent_id:
+ iffolder.parent_id!=self.parent_id:
+ self.parent_id=folder.parent_id
+ self.parent=self.get_parent_folder()ifupdate_parent_if_changedelseNone
+ self.child_folders_count=folder.child_folders_count
+ self.unread_items_count=folder.unread_items_count
+ self.total_items_count=folder.total_items_count
+ self.updated_at=folder.updated_at
+
+ returnTrue
+
+
[docs]defget_parent_folder(self):
+ """ Returns the parent folder from attribute self.parent or getting it from the cloud"""
+ ifself.root:
+ returnNone
+ ifself.parent:
+ returnself.parent
+
+ ifself.parent_id:
+ self.parent=self.get_folder(folder_id=self.parent_id)
+ returnself.parent
[docs]defcopy_folder(self,to_folder):
+ """
+ Copy this folder and it's contents to into another folder
+ :param to_folder: the destination Folder instance or a string folder_id
+ :return The copied folder object
+ """
+ to_folder_id=to_folder.folder_idifisinstance(to_folder,Folder)elseto_folder
+
+ ifself.rootornotself.folder_idornotto_folder_id:
+ returnNone
+
+ url=self.build_url(self._endpoints.get('copy_folder').format(id=self.folder_id))
+
+ response=self.con.post(url,data={self._cc('destinationId'):to_folder_id})
+ ifnotresponse:
+ returnNone
+
+ folder=response.json()
+
+ self_class=getattr(self,'folder_constructor',type(self))
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnself_class(con=self.con,main_resource=self.main_resource,**{self._cloud_data_key:folder})
+
+
[docs]defmove_folder(self,to_folder,*,update_parent_if_changed=False):
+ """
+ Move this folder to another folder
+ :param to_folder: the destination Folder instance or a string folder_id
+ :param update_parent_if_changed: updates self.parent with the new parent Folder if changed
+ """
+ to_folder_id=to_folder.folder_idifisinstance(to_folder,Folder)elseto_folder
+
+ ifself.rootornotself.folder_idornotto_folder_id:
+ returnFalse
+
+ url=self.build_url(self._endpoints.get('move_folder').format(id=self.folder_id))
+
+ response=self.con.post(url,data={self._cc('destinationId'):to_folder_id})
+ ifnotresponse:
+ returnFalse
+
+ folder=response.json()
+
+ parent_id=folder.get(self._cc('parentFolderId'),None)
+
+ ifparent_idandself.parent_id:
+ ifparent_id!=self.parent_id:
+ self.parent_id=parent_id
+ self.parent=self.get_parent_folder()ifupdate_parent_if_changedelseNone
+
+ returnTrue
+
+
[docs]defnew_message(self):
+ """ Creates a new draft message in this folder """
+
+ draft_message=self.message_constructor(parent=self,is_draft=True)
+
+ ifself.root:
+ draft_message.folder_id=OutlookWellKnowFolderNames.DRAFTS.value
+ else:
+ draft_message.folder_id=self.folder_id
+
+ returndraft_message
+
+
[docs]defdelete_message(self,message):
+ """ Deletes a stored message by it's id """
+
+ message_id=message.object_idifisinstance(message,Message)elsemessage
+
+ ifmessage_idisNone:
+ raiseRuntimeError('Provide a valid Message or a message id')
+
+ url=self.build_url(self._endpoints.get('delete_message').format(id=message_id))
+
+ response=self.con.delete(url)
+
+ returnbool(response)
+
+ def__bool__(self):
+ returnbool(self.address)
+
+ def__str__(self):
+ returnself.__repr__()
+
+ def__repr__(self):
+ ifself.name:
+ return'{} ({})'.format(self.name,self.address)
+ else:
+ returnself.address
+
+ def_track_changes(self):
+ """ Update the track_changes on the parent to reflect a needed update on this field """
+ ifself._fieldandgetattr(self._parent,'_track_changes',None)isnotNone:
+ self._parent._track_changes.add(self._field)
+
+ @property
+ defaddress(self):
+ returnself._address
+
+ @address.setter
+ defaddress(self,value):
+ self._address=value
+ self._track_changes()
+
+ @property
+ defname(self):
+ returnself._name
+
+ @name.setter
+ defname(self,value):
+ self._name=value
+ self._track_changes()
+
+
+
[docs]classRecipients:
+ """ A Sequence of Recipients """
+
+
[docs]def__init__(self,recipients=None,parent=None,field=None):
+ """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """
+ self._parent=parent
+ self._field=field
+ self._recipients=[]
+ self.untrack=True
+ ifrecipients:
+ self.add(recipients)
+ self.untrack=False
+
+ def__iter__(self):
+ returniter(self._recipients)
+
+ def__getitem__(self,key):
+ returnself._recipients[key]
+
+ def__contains__(self,item):
+ returnitemin{recipient.addressforrecipientinself._recipients}
+
+ def__bool__(self):
+ returnbool(len(self._recipients))
+
+ def__len__(self):
+ returnlen(self._recipients)
+
+ def__str__(self):
+ returnself.__repr__()
+
+ def__repr__(self):
+ return'Recipients count: {}'.format(len(self._recipients))
+
+ def_track_changes(self):
+ """ Update the track_changes on the parent to reflect a needed update on this field """
+ ifself._fieldandgetattr(self._parent,'_track_changes',None)isnotNoneandself.untrackisFalse:
+ self._parent._track_changes.add(self._field)
+
+
[docs]defadd(self,recipients):
+ """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """
+
+ ifrecipients:
+ ifisinstance(recipients,str):
+ self._recipients.append(Recipient(address=recipients,parent=self._parent,field=self._field))
+ elifisinstance(recipients,Recipient):
+ self._recipients.append(recipients)
+ elifisinstance(recipients,tuple):
+ name,address=recipients
+ ifaddress:
+ self._recipients.append(Recipient(address=address,name=name,parent=self._parent,field=self._field))
+ elifisinstance(recipients,list):
+ forrecipientinrecipients:
+ self.add(recipient)
+ else:
+ raiseValueError('Recipients must be an address string, a'
+ ' Recipient instance, a (name, address) tuple or a list')
+ self._track_changes()
[docs]defget_first_recipient_with_address(self):
+ """ Returns the first recipient found with a non blank address"""
+ recipients_with_address=[recipientforrecipientinself._recipientsifrecipient.address]
+ ifrecipients_with_address:
+ returnrecipients_with_address[0]
+ else:
+ returnNone
[docs]classHandleRecipientsMixin:
+
+ def_recipients_from_cloud(self,recipients,field=None):
+ """ Transform a recipient from cloud data to object data """
+ recipients_data=[]
+ forrecipientinrecipients:
+ recipients_data.append(self._recipient_from_cloud(recipient,field=field))
+ returnRecipients(recipients_data,parent=self,field=field)
+
+ def_recipient_from_cloud(self,recipient,field=None):
+ """ Transform a recipient from cloud data to object data """
+
+ ifrecipient:
+ recipient=recipient.get(self._cc('emailAddress'),recipientifisinstance(recipient,dict)else{})
+ address=recipient.get(self._cc('address'),'')
+ name=recipient.get(self._cc('name'),'')
+ returnRecipient(address=address,name=name,parent=self,field=field)
+ else:
+ returnRecipient()
+
+ def_recipient_to_cloud(self,recipient):
+ """ Transforms a Recipient object to a cloud dict """
+ data=None
+ ifrecipient:
+ data={self._cc('emailAddress'):{self._cc('address'):recipient.address}}
+ ifrecipient.name:
+ data[self._cc('emailAddress')][self._cc('name')]=recipient.name
+ returndata
+
+
+
[docs]classMessage(ApiComponent,AttachableMixin,HandleRecipientsMixin):
+ """ Management of the process of sending, receiving, reading, and editing emails. """
+
+ _endpoints={
+ 'create_draft':'/messages',
+ 'create_draft_folder':'/mailFolders/{id}/messages',
+ 'send_mail':'/sendMail',
+ 'send_draft':'/messages/{id}/send',
+ 'get_message':'/messages/{id}',
+ 'move_message':'/messages/{id}/move',
+ 'copy_message':'/messages/{id}/copy',
+ 'create_reply':'/messages/{id}/createReply',
+ 'create_reply_all':'/messages/{id}/createReplyAll',
+ 'forward_message':'/messages/{id}/createForward'
+ }
+
+
[docs]def__init__(self,*,parent=None,con=None,**kwargs):
+ """
+ Makes a new message wrapper for sending and receiving messages.
+
+ :param parent: the parent object
+ :param con: the id of this message if it exists
+ """
+ assertparentorcon,'Need a parent or a connection'
+ self.con=parent.conifparentelsecon
+
+ # Choose the main_resource passed in kwargs over the parent main_resource
+ main_resource=kwargs.pop('main_resource',None)orgetattr(parent,'main_resource',None)ifparentelseNone
+ super().__init__(protocol=parent.protocolifparentelsekwargs.get('protocol'),main_resource=main_resource,
+ attachment_name_property='subject',attachment_type='message_type')
+
+ download_attachments=kwargs.get('download_attachments')
+
+ cloud_data=kwargs.get(self._cloud_data_key,{})
+ cc=self._cc# alias to shorten the code
+
+ self._track_changes=TrackerSet(casing=cc)# internal to know which properties need to be updated on the server
+ self.object_id=cloud_data.get(cc('id'),None)
+
+ self.__created=cloud_data.get(cc('createdDateTime'),None)
+ self.__modified=cloud_data.get(cc('lastModifiedDateTime'),None)
+ self.__received=cloud_data.get(cc('receivedDateTime'),None)
+ self.__sent=cloud_data.get(cc('sentDateTime'),None)
+
+ local_tz=self.protocol.timezone
+ self.__created=parse(self.__created).astimezone(local_tz)ifself.__createdelseNone
+ self.__modified=parse(self.__modified).astimezone(local_tz)ifself.__modifiedelseNone
+ self.__received=parse(self.__received).astimezone(local_tz)ifself.__receivedelseNone
+ self.__sent=parse(self.__sent).astimezone(local_tz)ifself.__sentelseNone
+
+ self.__attachments=MessageAttachments(parent=self,attachments=[])
+ self.has_attachments=cloud_data.get(cc('hasAttachments'),0)
+ ifself.has_attachmentsanddownload_attachments:
+ self.attachments.download_attachments()
+ self.__subject=cloud_data.get(cc('subject'),'')
+ body=cloud_data.get(cc('body'),{})
+ self.__body=body.get(cc('content'),'')
+ self.body_type=body.get(cc('contentType'),'HTML')# default to HTML for new messages
+ self.__sender=self._recipient_from_cloud(cloud_data.get(cc('from'),None),field='from')
+ self.__to=self._recipients_from_cloud(cloud_data.get(cc('toRecipients'),[]),field='toRecipients')
+ self.__cc=self._recipients_from_cloud(cloud_data.get(cc('ccRecipients'),[]),field='ccRecipients')
+ self.__bcc=self._recipients_from_cloud(cloud_data.get(cc('bccRecipients'),[]),field='bccRecipients')
+ self.__reply_to=self._recipients_from_cloud(cloud_data.get(cc('replyTo'),[]),field='replyTo')
+ self.__categories=cloud_data.get(cc('categories'),[])
+ self.__importance=ImportanceLevel((cloud_data.get(cc('importance'),'normal')or'normal').lower())# lower because of office365 v1.0
+ self.__is_read=cloud_data.get(cc('isRead'),None)
+ self.__is_draft=cloud_data.get(cc('isDraft'),kwargs.get('is_draft',True))# a message is a draft by default
+ self.conversation_id=cloud_data.get(cc('conversationId'),None)
+ self.folder_id=cloud_data.get(cc('parentFolderId'),None)
+
+ def_clear_tracker(self):
+ # reset the tracked changes. Usually after a server update
+ self._track_changes=TrackerSet(casing=self._cc)
+
+ @property
+ defis_read(self):
+ returnself.__is_read
+
+ @is_read.setter
+ defis_read(self,value):
+ self.__is_read=value
+ self._track_changes.add('isRead')
+
+ @property
+ defis_draft(self):
+ returnself.__is_draft
+
+ @property
+ defsubject(self):
+ returnself.__subject
+
+ @subject.setter
+ defsubject(self,value):
+ self.__subject=value
+ self._track_changes.add('subject')
+
+ @property
+ defbody(self):
+ returnself.__body
+
+ @body.setter
+ defbody(self,value):
+ ifself.__body:
+ ifnotvalue:
+ self.__body=''
+ else:
+ soup=bs(self.__body,'html.parser')
+ soup.body.insert(0,bs(value,'html.parser'))
+ self.__body=str(soup)
+ else:
+ self.__body=value
+ self._track_changes.add('body')
+
+ @property
+ defcreated(self):
+ returnself.__created
+
+ @property
+ defmodified(self):
+ returnself.__modified
+
+ @property
+ defreceived(self):
+ returnself.__received
+
+ @property
+ defsent(self):
+ returnself.__sent
+
+ @property
+ defattachments(self):
+ """ Just to avoid api misuse by assigning to 'attachments' """
+ returnself.__attachments
+
+ @property
+ defsender(self):
+ """ sender is a property to force to be allways a Recipient class """
+ returnself.__sender
+
+ @sender.setter
+ defsender(self,value):
+ """ sender is a property to force to be allways a Recipient class """
+ ifisinstance(value,Recipient):
+ ifvalue._parentisNone:
+ value._parent=self
+ value._field='from'
+ self.__sender=value
+ elifisinstance(value,str):
+ self.__sender.address=value
+ self.__sender.name=''
+ else:
+ raiseValueError('sender must be an address string or a Recipient object')
+ self._track_changes.add('from')
+
+ @property
+ defto(self):
+ """ Just to avoid api misuse by assigning to 'to' """
+ returnself.__to
+
+ @property
+ defcc(self):
+ """ Just to avoid api misuse by assigning to 'cc' """
+ returnself.__cc
+
+ @property
+ defbcc(self):
+ """ Just to avoid api misuse by assigning to 'bcc' """
+ returnself.__bcc
+
+ @property
+ defreply_to(self):
+ """ Just to avoid api misuse by assigning to 'reply_to' """
+ returnself.__reply_to
+
+ @property
+ defcategories(self):
+ returnself.__categories
+
+ @categories.setter
+ defcategories(self,value):
+ ifisinstance(value,list):
+ self.__categories=value
+ elifisinstance(value,str):
+ self.__categories=[value]
+ elifisinstance(value,tuple):
+ self.__categories=list(value)
+ else:
+ raiseValueError('categories must be a list')
+ self._track_changes.add('categories')
+
+ @property
+ defimportance(self):
+ returnself.__importance
+
+ @importance.setter
+ defimportance(self,value):
+ self.__importance=valueifisinstance(value,ImportanceLevel)elseImportanceLevel(value.lower())
+ self._track_changes.add('importance')
+
+
[docs]defto_api_data(self,restrict_keys=None):
+ """ Returns a dict representation of this message prepared to be send to the cloud
+ :param restrict_keys: a set of keys to restrict the returned data to.
+ """
+
+ cc=self._cc# alias to shorten the code
+
+ message={
+ cc('subject'):self.subject,
+ cc('body'):{
+ cc('contentType'):self.body_type,
+ cc('content'):self.body},
+ cc('importance'):self.importance.value
+ }
+
+ ifself.to:
+ message[cc('toRecipients')]=[self._recipient_to_cloud(recipient)forrecipientinself.to]
+ ifself.cc:
+ message[cc('ccRecipients')]=[self._recipient_to_cloud(recipient)forrecipientinself.cc]
+ ifself.bcc:
+ message[cc('bccRecipients')]=[self._recipient_to_cloud(recipient)forrecipientinself.bcc]
+ ifself.reply_to:
+ message[cc('replyTo')]=[self._recipient_to_cloud(recipient)forrecipientinself.reply_to]
+ ifself.attachments:
+ message[cc('attachments')]=self.attachments.to_api_data()
+ ifself.senderandself.sender.address:
+ message[cc('from')]=self._recipient_to_cloud(self.sender)
+
+ ifself.object_idandnotself.__is_draft:
+ # return the whole signature of this message
+
+ message[cc('id')]=self.object_id
+ message[cc('createdDateTime')]=self.created.astimezone(pytz.utc).isoformat()
+ message[cc('receivedDateTime')]=self.received.astimezone(pytz.utc).isoformat()
+ message[cc('sentDateTime')]=self.sent.astimezone(pytz.utc).isoformat()
+ message[cc('hasAttachments')]=len(self.attachments)>0
+ message[cc('categories')]=self.categories
+ message[cc('isRead')]=self.is_read
+ message[cc('isDraft')]=self.__is_draft
+ message[cc('conversationId')]=self.conversation_id
+ message[cc('parentFolderId')]=self.folder_id# this property does not form part of the message itself
+
+ ifrestrict_keys:
+ forkeyinlist(message.keys()):
+ ifkeynotinrestrict_keys:
+ delmessage[key]
+
+ returnmessage
+
+
[docs]defsend(self,save_to_sent_folder=True):
+ """ Sends this message. """
+
+ ifself.object_idandnotself.__is_draft:
+ returnRuntimeError('Not possible to send a message that is not new or a draft. Use Reply or Forward instead.')
+
+ ifself.__is_draftandself.object_id:
+ url=self.build_url(self._endpoints.get('send_draft').format(id=self.object_id))
+ data=None
+ else:
+ url=self.build_url(self._endpoints.get('send_mail'))
+ data={self._cc('message'):self.to_api_data()}
+ ifsave_to_sent_folderisFalse:
+ data[self._cc('saveToSentItems')]=False
+
+ response=self.con.post(url,data=data)
+ ifnotresponse:# response evaluates to false if 4XX or 5XX status codes are returned
+ returnFalse
+
+ self.object_id='sent_message'ifnotself.object_idelseself.object_id
+ self.__is_draft=False
+
+ returnTrue
+
+
[docs]defreply(self,to_all=True):
+ """
+ Creates a new message that is a reply to this message.
+ :param to_all: replies to all the recipients instead to just the sender
+ """
+ ifnotself.object_idorself.__is_draft:
+ raiseRuntimeError("Can't reply to this message")
+
+ ifto_all:
+ url=self.build_url(self._endpoints.get('create_reply_all').format(id=self.object_id))
+ else:
+ url=self.build_url(self._endpoints.get('create_reply').format(id=self.object_id))
+
+ response=self.con.post(url)
+ ifnotresponse:
+ returnNone
+
+ message=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnself.__class__(parent=self,**{self._cloud_data_key:message})
+
+
[docs]defforward(self):
+ """
+ Creates a new message that is a forward of this message.
+ """
+ ifnotself.object_idorself.__is_draft:
+ raiseRuntimeError("Can't forward this message")
+
+ url=self.build_url(self._endpoints.get('forward_message').format(id=self.object_id))
+
+ response=self.con.post(url)
+ ifnotresponse:
+ returnNone
+
+ message=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnself.__class__(parent=self,**{self._cloud_data_key:message})
+
+
[docs]defdelete(self):
+ """ Deletes a stored message """
+ ifself.object_idisNone:
+ raiseRuntimeError('Attempting to delete an unsaved Message')
+
+ url=self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
+
+ response=self.con.delete(url)
+
+ returnbool(response)
+
+
[docs]defmark_as_read(self):
+ """ Marks this message as read in the cloud."""
+ ifself.object_idisNoneorself.__is_draft:
+ raiseRuntimeError('Attempting to mark as read an unsaved Message')
+
+ data={self._cc('isRead'):True}
+
+ url=self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
+
+ response=self.con.patch(url,data=data)
+ ifnotresponse:
+ returnFalse
+
+ self.__is_read=True
+
+ returnTrue
+
+
[docs]defmove(self,folder):
+ """
+ Move the message to a given folder
+
+ :param folder: Folder object or Folder id or Well-known name to move this message to
+ :returns: True on success
+ """
+ ifself.object_idisNone:
+ raiseRuntimeError('Attempting to move an unsaved Message')
+
+ url=self.build_url(self._endpoints.get('move_message').format(id=self.object_id))
+
+ ifisinstance(folder,str):
+ folder_id=folder
+ else:
+ folder_id=getattr(folder,'folder_id',None)
+
+ ifnotfolder_id:
+ raiseRuntimeError('Must Provide a valid folder_id')
+
+ data={self._cc('destinationId'):folder_id}
+
+ response=self.con.post(url,data=data)
+ ifnotresponse:
+ returnFalse
+
+ self.folder_id=folder_id
+
+ returnTrue
+
+
[docs]defcopy(self,folder):
+ """
+ Copy the message to a given folder
+
+ :param folder: Folder object or Folder id or Well-known name to move this message to
+ :returns: the copied message
+ """
+ ifself.object_idisNone:
+ raiseRuntimeError('Attempting to move an unsaved Message')
+
+ url=self.build_url(self._endpoints.get('copy_message').format(id=self.object_id))
+
+ ifisinstance(folder,str):
+ folder_id=folder
+ else:
+ folder_id=getattr(folder,'folder_id',None)
+
+ ifnotfolder_id:
+ raiseRuntimeError('Must Provide a valid folder_id')
+
+ data={self._cc('destinationId'):folder_id}
+
+ response=self.con.post(url,data=data)
+ ifnotresponse:
+ returnNone
+
+ message=response.json()
+
+ # Everything received from the cloud must be passed with self._cloud_data_key
+ returnself.__class__(parent=self,**{self._cloud_data_key:message})
+
+
[docs]defsave_draft(self,target_folder=OutlookWellKnowFolderNames.DRAFTS):
+ """ Save this message as a draft on the cloud """
+
+ ifself.object_id:
+ # update message. Attachments are NOT included nor saved.
+ ifnotself.__is_draft:
+ raiseRuntimeError('Only draft messages can be updated')
+ ifnotself._track_changes:
+ returnTrue# there's nothing to update
+ url=self.build_url(self._endpoints.get('get_message').format(id=self.object_id))
+ method=self.con.patch
+ data=self.to_api_data(restrict_keys=self._track_changes)
+
+ data.pop(self._cc('attachments'),None)# attachments are handled by the next method call
+ self.attachments._update_attachments_to_cloud()
+ else:
+ # new message. Attachments are included and saved.
+ ifnotself.__is_draft:
+ raiseRuntimeError('Only draft messages can be saved as drafts')
+
+ target_folder=target_folderorOutlookWellKnowFolderNames.DRAFTS
+ ifisinstance(target_folder,OutlookWellKnowFolderNames):
+ target_folder=target_folder.value
+ elifnotisinstance(target_folder,str):
+ # a Folder instance
+ target_folder=getattr(target_folder,'folder_id',OutlookWellKnowFolderNames.DRAFTS.value)
+
+ url=self.build_url(self._endpoints.get('create_draft_folder').format(id=target_folder))
+ method=self.con.post
+ data=self.to_api_data()
+
+ self._clear_tracker()# reset the tracked changes as they are all saved.
+ ifnotdata:
+ returnTrue
+
+ response=method(url,data=data)
+ ifnotresponse:
+ returnFalse
+
+ ifnotself.object_id:
+ # new message
+ message=response.json()
+
+ self.object_id=message.get(self._cc('id'),None)
+ self.folder_id=message.get(self._cc('parentFolderId'),None)
+
+ self.__created=message.get(self._cc('createdDateTime'),message.get(self._cc('dateTimeCreated'),None))# fallback to office365 v1.0
+ self.__modified=message.get(self._cc('lastModifiedDateTime'),message.get(self._cc('dateTimeModified'),None))# fallback to office365 v1.0
+
+ self.__created=parse(self.__created).astimezone(self.protocol.timezone)ifself.__createdelseNone
+ self.__modified=parse(self.__modified).astimezone(self.protocol.timezone)ifself.__modifiedelseNone
+
+ else:
+ self.__modified=self.protocol.timezone.localize(dt.datetime.now())
+
+ returnTrue
+
+
[docs]defget_body_text(self):
+ """ Parse the body html and returns the body text using bs4 """
+ ifself.body_type!='HTML':
+ returnself.body
+
+ try:
+ soup=bs(self.body,'html.parser')
+ exceptExceptionase:
+ returnself.body
+ else:
+ returnsoup.body.text
+
+
[docs]defget_body_soup(self):
+ """ Returns the beautifulsoup4 of the html body"""
+ ifself.body_type!='HTML':
+ returnNone
+ else:
+ returnbs(self.body,'html.parser')