Skip to content

Commit

Permalink
CSC-5399 Utilise requests library (#31)
Browse files Browse the repository at this point in the history
* CSC-5399 Change SSL config

* CSC-5399 Change TLS to 1.2

* CSC-5403 Add retries for SSLEOFError

* CSC-5403 Add max_entries param to contacts delta_list

* CSC-5399 Two way sync fails with SSLEOFError

* CSC-5439 The Send later functionality doesn't work in Outlook - Attempts to send the delayed email have failed

---------

Co-authored-by: Andrew Matsukov <[email protected]>
  • Loading branch information
1 parent d8edf24 commit f0040c4
Show file tree
Hide file tree
Showing 6 changed files with 69 additions and 72 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.pyc
.vscode/*
.venv
10 changes: 6 additions & 4 deletions office365_api/v2/client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
import httplib2
from .services import UserServicesFactory, SubscriptionFactory, BatchService
import requests

from .services import BatchService, SubscriptionFactory, UserServicesFactory


class MicrosoftGraphClient(object):
def __init__(self, credentials):
self.credentials = credentials
self.http = httplib2.Http()
self.credentials.authorize(self.http)
self.http = None # backward compatibility
self.session = requests.Session()
self.credentials.apply(self.session.headers)

self.users = UserServicesFactory(self)
self.me = self.users('me')
Expand Down
14 changes: 8 additions & 6 deletions office365_api/v2/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
class Office365ClientError(Exception):

def __init__(self, status_code, data):
super(Office365ClientError, self).__init__('{}: {}: {}'.format(
status_code, data['error']['code'], data['error']['message']))
self.status_code = status_code
self.error_code = data['error']['code']
self.error_message = data['error']['message']
self.error_code = data.get('error', {}).get('code', '')
self.error_message = data.get('error', {}).get('message', '')
super(Office365ClientError, self).__init__('{}: {}: {}'.format(
status_code,
self.error_code,
self.error_message))

@property
def is_invalid_tokens(self):
Expand All @@ -20,7 +22,7 @@ def is_invalid_tokens(self):
def is_invalid_session(self):
# Need to use refresh_token
return self.status_code == 401

@property
def is_forbidden(self):
return self.status_code == 403
Expand All @@ -39,7 +41,7 @@ def __repr__(self):


class Office365ServerError(Exception):

def __init__(self, status_code, body):
super(Office365ServerError, self).__init__(
'{}: {}'.format(status_code, body))
Expand Down
105 changes: 48 additions & 57 deletions office365_api/v2/services.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
import json
import logging
from typing import Any, Dict, List, Tuple
import urllib.request
import urllib.parse
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Tuple

import oauth2client.transport
from requests.exceptions import ChunkedEncodingError
from requests.exceptions import ConnectionError as RequestsConnectionError

from .exceptions import Office365ClientError, Office365ServerError

Expand All @@ -22,7 +23,7 @@ class BaseService(object):
base_url = 'https://graph.microsoft.com'
graph_api_version = 'v1.0'
supported_response_formats = [RESPONSE_FORMAT_ODATA, RESPONSE_FORMAT_RAW]


def __init__(self, client, prefix):
self.client = client
Expand All @@ -47,7 +48,7 @@ def follow_next_link(self, next_link, max_entries=DEFAULT_MAX_ENTRIES, fields=[]
return resp, next_link

def execute_request(self, method, path, query_params=None, headers=None, body=None,
parse_json_result=True):
parse_json_result=True, set_content_type=True):
"""
Run the http request and returns the json data upon success.
Expand All @@ -61,11 +62,14 @@ def execute_request(self, method, path, query_params=None, headers=None, body=No
querystring = urllib.parse.urlencode(query_params)
full_url += '?' + querystring

default_headers = {
'Content-Type': 'application/json'
} if parse_json_result else {
'Content-Type': 'text/html'
}
if set_content_type:
default_headers = {
'Content-Type': 'application/json'
} if parse_json_result else {
'Content-Type': 'text/html'
}
else:
default_headers = {}

if headers:
default_headers.update(headers)
Expand All @@ -74,28 +78,27 @@ def execute_request(self, method, path, query_params=None, headers=None, body=No
retries = RETRIES_COUNT
while True:
try:
resp, content = oauth2client.transport.request(self.client.http,
full_url,
method=method.upper(),
body=body,
headers=default_headers)
resp = self.client.session.request(url=full_url, method=method.upper(), data=body, headers=default_headers)
break
except ConnectionResetError:
except (
ConnectionResetError,
# requests lib re-raises ConnectionResetError exception as one of below
RequestsConnectionError,
ChunkedEncodingError, ):
retries -= 1
if retries == 0:
raise

if resp.status < 300:
if content:
return json.loads(content) if parse_json_result else content
elif resp.status < 500:
if resp.status_code < 300:
return resp.json() if parse_json_result else resp.content
elif resp.status_code < 500:
try:
error_data = json.loads(content)
error_data = resp.json()
except ValueError:
error_data = {'error': {'message': content, 'code': 'uknown'}}
raise Office365ClientError(resp.status, error_data)
error_data = {'error': {'message': resp.content, 'code': 'uknown'}}
raise Office365ClientError(resp.status_code, error_data)
else:
raise Office365ServerError(resp.status, content)
raise Office365ServerError(resp.status_code, resp.content)


class ServicesCollection(object):
Expand Down Expand Up @@ -177,23 +180,21 @@ def _execute(self, requests):

logger.info('{}: {} with {}x requests'.format(
method, self.batch_uri, len(requests)))
resp, content = oauth2client.transport.request(self.client.http,
self.batch_uri,
method=method,
body=json.dumps(
{'requests': requests}),
headers=default_headers)
if resp.status < 300:
if content:
return json.loads(content)
elif resp.status < 500:
resp = self.client.session.request(
url=self.batch_uri,
method=method,
json={'requests': requests},
headers=default_headers)
if resp.status_code < 300:
return resp.json()
elif resp.status_code < 500:
try:
error_data = json.loads(content)
error_data = resp.json()
except ValueError:
error_data = {'error': {'message': content, 'code': 'unknown'}}
raise Office365ClientError(resp.status, error_data)
error_data = {'error': {'message': resp.content, 'code': 'unknown'}}
raise Office365ClientError(resp.status_code, error_data)
else:
raise Office365ServerError(resp.status, content)
raise Office365ServerError(resp.status_code, resp.content)

def execute(self):
requests = []
Expand Down Expand Up @@ -443,7 +444,7 @@ def get(self, message_id, _filter=None, format=RESPONSE_FORMAT_ODATA):
"""https://graph.microsoft.io/en-us/docs/api-reference/v1.0/api/user_list_messages ."""
if format not in self.supported_response_formats:
raise ValueError(format)

if format == RESPONSE_FORMAT_ODATA:
path = '/messages/{}'.format(message_id)
elif format == RESPONSE_FORMAT_RAW:
Expand All @@ -464,23 +465,10 @@ def create(self, **kwargs):
def send(self, message_id, **kwargs):
"""https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/message_send ."""
path = '/messages/{}/send'.format(message_id)
method = 'post'
# this request fails if Content-Type header is set
# to work around this, we don't use self.execute_request()
resp, content = oauth2client.transport.request(self.client.http,
self.build_url(path),
method='POST',
headers={'Content-Length': 0})
if resp.status < 300:
if content:
return json.loads(content)
elif resp.status < 500:
try:
error_data = json.loads(content)
except ValueError:
error_data = {'error': {'message': content, 'code': 'uknown'}}
raise Office365ClientError(resp.status, error_data)
else:
raise Office365ServerError(resp.status, content)
return self.execute_request(method, path, headers={'Content-Length': '0'}, set_content_type=False, parse_json_result=False)

def update(self, message_id, **kwargs):
path = '/messages/{}'.format(message_id)
Expand Down Expand Up @@ -557,7 +545,7 @@ def create(self, **kwargs):
body = json.dumps(kwargs)
return self.execute_request(method, path, body=body)

def delta_list(self, folder_id: str = 'contacts', fields: List[str] = [], delta_token: str = None) -> Tuple[Dict[str, Any], str]:
def delta_list(self, folder_id: str = 'contacts', fields: List[str] = [], delta_token: str = None, max_entries=DEFAULT_MAX_ENTRIES) -> Tuple[Dict[str, Any], str]:
path = f"/contactFolders('{folder_id}')/contacts/delta"
method = 'get'
query_params = None
Expand All @@ -569,7 +557,10 @@ def delta_list(self, folder_id: str = 'contacts', fields: List[str] = [], delta_
query_params = {
'$select': ','.join(fields)
}
resp = self.execute_request(method, path, query_params=query_params)
headers = {
'Prefer': 'odata.maxpagesize=%d' % max_entries
}
resp = self.execute_request(method, path, query_params=query_params, headers=headers)
next_link = resp.get('@odata.nextLink')
return resp, next_link

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
oauth2client==4.1.3
requests>=2.31.0
10 changes: 5 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
from setuptools import setup, find_packages

from setuptools import find_packages, setup

setup(name='office365-rest-client',
version='3.2.2',
description='Python api wrapper for Office365 API v3.2.2',
version='3.3.0',
description='Python api wrapper for Office365 API v3.3.0',
author='SugarCRM',
packages=find_packages(),
install_requires=[
'oauth2client>=4.0.0'
'oauth2client>=4.0.0',
'requests>=2.31.0',
],
zip_safe=False)

0 comments on commit f0040c4

Please sign in to comment.