text: import logging
  from enum import Enum
  import datetime as dt
  import pytz
  from collections import OrderedDict
  
  ME_RESOURCE = 'me'
  USERS_RESOURCE = 'users'
  
  NEXT_LINK_KEYWORD = '@odata.nextLink'
  
  log = logging.getLogger(__name__)
  
  MAX_RECIPIENTS_PER_MESSAGE = 500  # Actual limit on Office 365
  
  
  class ImportanceLevel(Enum):
      Normal = 'normal'
      Low = 'low'
      High = 'high'
  
  
  class OutlookWellKnowFolderNames(Enum):
      INBOX = 'Inbox'
      JUNK = 'JunkEmail'
      DELETED = 'DeletedItems'
      DRAFTS = 'Drafts'
      SENT = 'SentItems'
      OUTBOX = 'Outbox'
  
  
  class OneDriveWellKnowFolderNames(Enum):
      DOCUMENTS = 'documents'
      PHOTOS = 'photos'
      CAMERA_ROLL = 'cameraroll'
      APP_ROOT = 'approot'
      MUSIC = 'music'
      ATTACHMENTS = 'attachments'
  
  
  class ChainOperator(Enum):
      AND = 'and'
      OR = 'or'
  
  
  class TrackerSet(set):
      """ A Custom Set that changes the casing of it's keys """
  
      def __init__(self, *args, casing=None, **kwargs):
          self.cc = casing
          super().__init__(*args, **kwargs)
  
      def add(self, value):
          value = self.cc(value)
          super().add(value)
  
  
  class ApiComponent:
      """ Base class for all object interactions with the Cloud Service API
  
      Exposes common access methods to the api protocol within all Api objects
      """
  
      _cloud_data_key = '__cloud_data__'  # wrapps cloud data with this dict key
      _endpoints = {}  # dict of all API service endpoints needed
  
      def __init__(self, *, protocol=None, main_resource=None, **kwargs):
          """ Object initialization
          :param protocol: A protocol class or instance to be used with this connection
          :param main_resource: main_resource to be used in these API comunications
          :param kwargs: Extra arguments
          """
          self.protocol = protocol() if isinstance(protocol, type) else protocol
          if self.protocol is None:
              raise ValueError('Protocol not provided to Api Component')
          self.main_resource = self._parse_resource(main_resource or protocol.default_resource)
          self._base_url = '{}{}'.format(self.protocol.service_url, self.main_resource)
          super().__init__()
  
      @staticmethod
      def _parse_resource(resource):
          resource = resource.strip() if resource else resource
          """ Parses and completes resource information """
          if resource == ME_RESOURCE:
              return resource
          elif resource == USERS_RESOURCE:
              return resource
          elif '/' not in resource and USERS_RESOURCE not in resource:
              # when for example accesing a shared mailbox the resouse is set to the email address.
              # we have to prefix the email with the resource 'users/' so --> 'users/email_address'
              return '{}/{}'.format(USERS_RESOURCE, resource)
          else:
              return resource
  
      def build_url(self, endpoint):
          """ Returns a url for a given endpoint using the protocol service url """
          return '{}{}'.format(self._base_url, endpoint)
  
      def _gk(self, keyword):
          """ Alias for protocol.get_service_keyword """
          return self.protocol.get_service_keyword(keyword)
  
      def _cc(self, dict_key):
          """ Alias for protocol.convert_case """
          return self.protocol.convert_case(dict_key)
  
      def new_query(self, attribute=None):
          return Query(attribute=attribute, protocol=self.protocol)
  
      q = new_query  # alias for new query
  
  
  class Pagination(ApiComponent):
      """ Utility class that allows batching requests to the server """
  
      def __init__(self, *, parent=None, data=None, constructor=None, next_link=None, limit=None):
          """
          Returns an iterator that returns data until it's exhausted. Then will request more data
          (same amount as the original request) to the server until this data is exhausted as well.
          Stops when no more data exists or limit is reached.
  
          :param parent: the parent class. Must implement attributes:
              con, api_version, main_resource
          :param data: the start data to be return
          :param constructor: the data constructor for the next batch. It can be a function.
          :param next_link: the link to request more data to
          :param limit: when to stop retrieving more data
          """
          if parent is None:
              raise ValueError('Parent must be another Api Component')
  
          super().__init__(protocol=parent.protocol, main_resource=parent.main_resource)
  
          self.parent = parent
          self.con = parent.con
          self.constructor = constructor
          self.next_link = next_link
          self.limit = limit
          self.data = data if data else []
  
          data_count = len(data)
          if limit and limit < data_count:
              self.data_count = limit
              self.total_count = limit
          else:
              self.data_count = data_count
              self.total_count = data_count
          self.state = 0
  
      def __str__(self):
          return self.__repr__()
  
      def __repr__(self):
          if callable(self.constructor):
              return 'Pagination Iterator'
          else:
              return "'{}' Iterator".format(self.constructor.__name__ if self.constructor else 'Unknown')
  
      def __bool__(self):
          return bool(self.data) or bool(self.next_link)
  
      def __iter__(self):
          return self
  
      def __next__(self):
          if self.state < self.data_count:
              value = self.data[self.state]
              self.state += 1
              return value
          else:
              if self.limit and self.total_count >= self.limit:
                  raise StopIteration()
  
          if self.next_link is None:
              raise StopIteration()
  
          response = self.con.get(self.next_link)
          if not response:
              raise StopIteration()
  
          data = response.json()
  
          self.next_link = data.get(NEXT_LINK_KEYWORD, None) or None
          data = data.get('value', [])
          if self.constructor:
              # Everything received from the cloud must be passed with self._cloud_data_key
              if callable(self.constructor) and not isinstance(self.constructor, type):
                  self.data = [self.constructor(value)(parent=self.parent, **{self._cloud_data_key: value}) for value in data]
              else:
                  self.data = [self.constructor(parent=self.parent, **{self._cloud_data_key: value}) for value in data]
          else:
              self.data = data
  
          items_count = len(data)
          if self.limit:
              dif = self.limit - (self.total_count + items_count)
              if dif < 0:
                  self.data = self.data[:dif]
                  self.next_link = None  # stop batching
                  items_count = items_count + dif
          if items_count:
              self.data_count = items_count
              self.total_count += items_count
              self.state = 0
              value = self.data[self.state]
              self.state += 1
              return value
          else:
              raise StopIteration()
  
  
  class Query:
      """ Helper to conform OData filters """
      _mapping = {
          'from': 'from/emailAddress/address',
          'to': 'toRecipients/emailAddress/address',
          'start': 'start/DateTime',
          'end': 'end/DateTime'
      }
  
      def __init__(self, attribute=None, *, protocol):
          self.protocol = protocol() if isinstance(protocol, type) else protocol
          self._attribute = None
          self._chain = None
          self.new(attribute)
          self._negation = False
          self._filters = []
          self._order_by = OrderedDict()
          self._selects = set()
  
      def __str__(self):
          return 'Filter: {}\nOrder: {}\nSelect: {}'.format(self.get_filters(), self.get_order(), self.get_selects())
  
      def __repr__(self):
          return self.__str__()
  
      def select(self, *attributes):
          """
          Adds the attribute to the $select parameter
          :param attributes: the attributes tuple to select. If empty, the on_attribute previously set is added.
          """
          if attributes:
              for attribute in attributes:
                  attribute = self.protocol.convert_case(attribute) if attribute and isinstance(attribute, str) else None
                  if attribute:
                      if '/' in attribute:
                          # only parent attribute can be selected
                          attribute = attribute.split('/')[0]
                      self._selects.add(attribute)
          else:
              if self._attribute:
                  self._selects.add(self._attribute)
  
          return self
  
      def as_params(self):
          """ Returns the filters and orders as query parameters"""
          params = {}
          if self.has_filters:
              params['$filter'] = self.get_filters()
          if self.has_order:
              params['$orderby'] = self.get_order()
          if self.has_selects:
              params['$select'] = self.get_selects()
          return params
  
      @property
      def has_filters(self):
          return bool(self._filters)
  
      @property
      def has_order(self):
          return bool(self._order_by)
  
      @property
      def has_selects(self):
          return bool(self._selects)
  
      def get_filters(self):
          """ Returns the result filters """
          if self._filters:
              filters_list = self._filters
              if isinstance(filters_list[-1], Enum):
                  filters_list = filters_list[:-1]
              return ' '.join([fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list]).strip()
          else:
              return None
  
      def get_order(self):
          """ Returns the result order by clauses """
          # first get the filtered attributes in order as they must appear in the order_by first
          if not self.has_order:
              return None
          filter_order_clauses = OrderedDict([(filter_attr[0], None)
                                              for filter_attr in self._filters
                                              if isinstance(filter_attr, tuple)])
  
          # any order_by attribute that appears in the filters is ignored
          order_by_dict = self._order_by.copy()
          for filter_oc in filter_order_clauses.keys():
              direction = order_by_dict.pop(filter_oc, None)
              filter_order_clauses[filter_oc] = direction
  
          filter_order_clauses.update(order_by_dict)  # append any remaining order_by clause
  
          if filter_order_clauses:
              return ','.join(['{} {}'.format(attribute, direction if direction else '').strip()
                               for attribute, direction in filter_order_clauses.items()])
          else:
              return None
  
      def get_selects(self):
          """ Returns the result select clause """
          if self._selects:
              return ','.join(self._selects)
          else:
              return None
  
      def _get_mapping(self, attribute):
          if attribute:
              mapping = self._mapping.get(attribute)
              if mapping:
                  attribute = '/'.join([self.protocol.convert_case(step) for step in mapping.split('/')])
              else:
                  attribute = self.protocol.convert_case(attribute)
              return attribute
          return None
  
      def new(self, attribute, operation=ChainOperator.AND):
          if isinstance(operation, str):
              operation = ChainOperator(operation)
          self._chain = operation
          self._attribute = self._get_mapping(attribute) if attribute else None
          self._negation = False
          return self
  
      def clear_filters(self):
          self._filters = []
  
      def clear(self):
          self._filters = []
          self._order_by = OrderedDict()
          self._selects = set()
          self.new(None)
          return self
  
      def negate(self):
          self._negation = not self._negation
          return self
  
      def chain(self, operation=ChainOperator.AND):
          if isinstance(operation, str):
              operation = ChainOperator(operation)
          self._chain = operation
          return self
  
      def on_attribute(self, attribute):
          self._attribute = self._get_mapping(attribute)
          return self
  
      def _add_filter(self, filter_str):
          if self._attribute:
              if self._filters and not isinstance(self._filters[-1], ChainOperator):
                  self._filters.append(self._chain)
              self._filters.append((self._attribute, filter_str))
          else:
              raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)')
  
      def _parse_filter_word(self, word):
          """ Converts the word parameter into the correct format """
          if isinstance(word, str):
              word = "'{}'".format(word)
          elif isinstance(word, dt.date):
              if isinstance(word, dt.datetime):
                  if word.tzinfo is None:
                      # if it's a naive datetime, localize the datetime.
                      word = self.protocol.timezone.localize(word)  # localize datetime into local tz
                  if word.tzinfo != pytz.utc:
                      word = word.astimezone(pytz.utc)  # transform local datetime to utc
              word = '{}'.format(word.isoformat())  # convert datetime to isoformat
          elif isinstance(word, bool):
              word = str(word).lower()
          return word
  
      def logical_operator(self, operation, word):
          word = self._parse_filter_word(word)
          sentence = '{} {} {} {}'.format('not' if self._negation else '', self._attribute, operation, word).strip()
          self._add_filter(sentence)
          return self
  
      def equals(self, word):
          return self.logical_operator('eq', word)
  
      def unequal(self, word):
          return self.logical_operator('ne', word)
  
      def greater(self, word):
          return self.logical_operator('gt', word)
  
      def greater_equal(self, word):
          return self.logical_operator('ge', word)
  
      def less(self, word):
          return self.logical_operator('lt', word)
  
      def less_equal(self, word):
          return self.logical_operator('le', word)
  
      def function(self, function_name, word):
          word = self._parse_filter_word(word)
  
          self._add_filter(
              "{} {}({}, {})".format('not' if self._negation else '', function_name, self._attribute, word).strip())
          return self
  
      def contains(self, word):
          return self.function('contains', word)
  
      def startswith(self, word):
          return self.function('startswith', word)
  
      def endswith(self, word):
          return self.function('endswith', word)
  
      def order_by(self, attribute=None, *, ascending=True):
          """ applies a order_by clause"""
          attribute = self._get_mapping(attribute) or self._attribute
          if attribute:
              self._order_by[attribute] = None if ascending else 'desc'
          else:
              raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)')
          return self
  