Skip to content

Commit 2a392fe

Browse files
author
Alejandro Casanovas
committed
Added type hinting to Protocol class
Protocol will now raise an error of provided timezone is not found get_iana_tz now returns a ZoneInfo
1 parent bb3086b commit 2a392fe

File tree

3 files changed

+64
-62
lines changed

3 files changed

+64
-62
lines changed

O365/connection.py

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import time
5+
from typing import Optional, Callable, Union
56

67
from oauthlib.oauth2 import TokenExpiredError, WebApplicationClient, BackendApplicationClient, LegacyApplicationClient
78
from requests import Session
@@ -68,64 +69,68 @@ class Protocol:
6869
_oauth_scope_prefix = '' # Prefix for scopes
6970
_oauth_scopes = {} # Dictionary of {scopes_name: [scope1, scope2]}
7071

71-
def __init__(self, *, protocol_url=None, api_version=None,
72-
default_resource=None,
73-
casing_function=None, protocol_scope_prefix=None,
74-
timezone=None, **kwargs):
72+
def __init__(self, *, protocol_url: Optional[str] = None,
73+
api_version: Optional[str] = None,
74+
default_resource: Optional[str] = None,
75+
casing_function: Optional[Callable] = None,
76+
protocol_scope_prefix: Optional[str] = None,
77+
timezone: Union[Optional[str], Optional[ZoneInfo]] = None, **kwargs):
7578
""" Create a new protocol object
7679
77-
:param str protocol_url: the base url used to communicate with the
80+
:param protocol_url: the base url used to communicate with the
7881
server
79-
:param str api_version: the api version
80-
:param str default_resource: the default resource to use when there is
82+
:param api_version: the api version
83+
:param default_resource: the default resource to use when there is
8184
nothing explicitly specified during the requests
82-
:param function casing_function: the casing transform function to be
85+
:param casing_function: the casing transform function to be
8386
used on api keywords (camelcase / pascalcase)
84-
:param str protocol_scope_prefix: prefix url for scopes
85-
:param datetime.timezone.utc or str timezone: preferred timezone, defaults to the
86-
system timezone or fallback to UTC
87+
:param protocol_scope_prefix: prefix url for scopes
88+
:param timezone: preferred timezone, if not provided will default
89+
to the system timezone or fallback to UTC
8790
:raises ValueError: if protocol_url or api_version are not supplied
8891
"""
8992
if protocol_url is None or api_version is None:
9093
raise ValueError(
9194
'Must provide valid protocol_url and api_version values')
92-
self.protocol_url = protocol_url or self._protocol_url
93-
self.protocol_scope_prefix = protocol_scope_prefix or ''
94-
self.api_version = api_version
95-
self.service_url = '{}{}/'.format(protocol_url, api_version)
96-
self.default_resource = default_resource or ME_RESOURCE
97-
self.use_default_casing = True if casing_function is None else False
98-
self.casing_function = casing_function or camelcase
95+
self.protocol_url: str = protocol_url or self._protocol_url
96+
self.protocol_scope_prefix: str = protocol_scope_prefix or ''
97+
self.api_version: str = api_version
98+
self.service_url: str = '{}{}/'.format(protocol_url, api_version)
99+
self.default_resource: str = default_resource or ME_RESOURCE
100+
self.use_default_casing: bool = True if casing_function is None else False
101+
self.casing_function: Callable = casing_function or camelcase
102+
99103
if timezone:
100104
if isinstance(timezone, str):
101105
# convert string to ZoneInfo
102106
try:
103107
timezone = ZoneInfo(timezone)
104-
except ZoneInfoNotFoundError:
105-
log.debug(f'Timezone {timezone} could not be found. Will default to UTC.')
106-
timezone = ZoneInfo('UTC')
108+
except ZoneInfoNotFoundError as e:
109+
log.error(f'Timezone {timezone} could not be found.')
110+
raise e
107111
else:
108112
if not isinstance(timezone, ZoneInfo):
109113
raise ValueError(f'The timezone parameter must be either a string or a valid ZoneInfo instance.')
114+
110115
# get_localzone() from tzlocal will try to get the system local timezone and if not will return UTC
111-
self.timezone = timezone or get_localzone()
112-
self.max_top_value = 500 # Max $top parameter value
116+
self.timezone: ZoneInfo = timezone or get_localzone()
117+
self.max_top_value: int = 500 # Max $top parameter value
113118

114119
# define any keyword that can be different in this protocol
115-
# for example, attachments Odata type differs between Outlook
120+
# for example, attachments OData type differs between Outlook
116121
# rest api and graph: (graph = #microsoft.graph.fileAttachment and
117122
# outlook = #Microsoft.OutlookServices.FileAttachment')
118-
self.keyword_data_store = {}
123+
self.keyword_data_store: dict = {}
119124

120-
def get_service_keyword(self, keyword):
125+
def get_service_keyword(self, keyword: str) -> str:
121126
""" Returns the data set to the key in the internal data-key dict
122127
123-
:param str keyword: key to get value for
128+
:param keyword: key to get value for
124129
:return: value of the keyword
125130
"""
126131
return self.keyword_data_store.get(keyword, None)
127132

128-
def convert_case(self, key):
133+
def convert_case(self, key: str) -> str:
129134
""" Returns a key converted with this protocol casing method
130135
131136
Converts case to send/read from the cloud
@@ -137,30 +142,26 @@ def convert_case(self, key):
137142
138143
Default case in this API is lowerCamelCase
139144
140-
:param str key: a dictionary key to convert
145+
:param key: a dictionary key to convert
141146
:return: key after case conversion
142-
:rtype: str
143147
"""
144148
return key if self.use_default_casing else self.casing_function(key)
145149

146150
@staticmethod
147-
def to_api_case(key):
151+
def to_api_case(key: str) -> str:
148152
""" Converts key to snake_case
149153
150-
:param str key: key to convert into snake_case
154+
:param key: key to convert into snake_case
151155
:return: key after case conversion
152-
:rtype: str
153156
"""
154157
return snakecase(key)
155158

156-
def get_scopes_for(self, user_provided_scopes):
159+
def get_scopes_for(self, user_provided_scopes: Optional[Union[list, str, tuple]]) -> list:
157160
""" Returns a list of scopes needed for each of the
158161
scope_helpers provided, by adding the prefix to them if required
159162
160163
:param user_provided_scopes: a list of scopes or scope helpers
161-
:type user_provided_scopes: list or tuple or str
162164
:return: scopes with url prefix added
163-
:rtype: list
164165
:raises ValueError: if unexpected datatype of scopes are passed
165166
"""
166167
if user_provided_scopes is None:
@@ -170,8 +171,7 @@ def get_scopes_for(self, user_provided_scopes):
170171
user_provided_scopes = [user_provided_scopes]
171172

172173
if not isinstance(user_provided_scopes, (list, tuple)):
173-
raise ValueError(
174-
"'user_provided_scopes' must be a list or a tuple of strings")
174+
raise ValueError("'user_provided_scopes' must be a list or a tuple of strings")
175175

176176
scopes = set()
177177
for app_part in user_provided_scopes:
@@ -180,7 +180,7 @@ def get_scopes_for(self, user_provided_scopes):
180180

181181
return list(scopes)
182182

183-
def prefix_scope(self, scope):
183+
def prefix_scope(self, scope: Union[tuple, str]) -> str:
184184
""" Inserts the protocol scope prefix if required"""
185185
if self.protocol_scope_prefix:
186186
if isinstance(scope, tuple):
@@ -275,7 +275,6 @@ def __init__(self, api_version='v2.0', default_resource=None,
275275

276276

277277
class MSBusinessCentral365Protocol(Protocol):
278-
279278
""" A Microsoft Business Central Protocol Implementation
280279
https://docs.microsoft.com/en-us/dynamics-nav/api-reference/v1.0/endpoints-apis-for-dynamics
281280
"""
@@ -285,7 +284,7 @@ class MSBusinessCentral365Protocol(Protocol):
285284
_oauth_scopes = DEFAULT_SCOPES
286285
_protocol_scope_prefix = 'https://api.businesscentral.dynamics.com/'
287286

288-
def __init__(self, api_version='v1.0', default_resource=None,environment=None,
287+
def __init__(self, api_version='v1.0', default_resource=None, environment=None,
289288
**kwargs):
290289
""" Create a new Microsoft Graph protocol object
291290
@@ -299,7 +298,7 @@ def __init__(self, api_version='v1.0', default_resource=None,environment=None,
299298
"""
300299
if environment:
301300
_version = "2.0"
302-
_environment = "/"+environment
301+
_environment = "/" + environment
303302
else:
304303
_version = "1.0"
305304
_environment = ''
@@ -385,7 +384,8 @@ def __init__(self, credentials, *, scopes=None,
385384
if not isinstance(credentials, tuple) or len(credentials) != 1 or (not credentials[0]):
386385
raise ValueError('Provide client id only for public or password flow credentials')
387386
else:
388-
if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]):
387+
if not isinstance(credentials, tuple) or len(credentials) != 2 or (
388+
not credentials[0] and not credentials[1]):
389389
raise ValueError('Provide valid auth credentials')
390390

391391
self._auth_flow_type = auth_flow_type # 'authorization', 'credentials', 'certificate', 'password', or 'public'
@@ -440,12 +440,12 @@ def set_proxy(self, proxy_server, proxy_port, proxy_username,
440440
if proxy_server and proxy_port:
441441
if proxy_username and proxy_password:
442442
proxy_uri = "{}:{}@{}:{}".format(proxy_username,
443-
proxy_password,
444-
proxy_server,
445-
proxy_port)
443+
proxy_password,
444+
proxy_server,
445+
proxy_port)
446446
else:
447447
proxy_uri = "{}:{}".format(proxy_server,
448-
proxy_port)
448+
proxy_port)
449449

450450
if proxy_http_only is False:
451451
self.proxy = {
@@ -823,7 +823,8 @@ def _internal_request(self, request_obj, url, method, **kwargs):
823823
log.debug('Server Error: {}'.format(str(e)))
824824
if self.raise_http_errors:
825825
if error_message:
826-
raise HTTPError('{} | Error Message: {}'.format(e.args[0], error_message), response=response) from None
826+
raise HTTPError('{} | Error Message: {}'.format(e.args[0], error_message),
827+
response=response) from None
827828
else:
828829
raise e
829830
else:
@@ -925,7 +926,7 @@ def __del__(self):
925926
But this is not an issue because this connections will be automatically closed.
926927
"""
927928
if hasattr(self, 'session') and self.session is not None:
928-
self.session.close()
929+
self.session.close()
929930

930931

931932
def oauth_authentication_flow(client_id, client_secret, scopes=None,

O365/utils/utils.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,7 @@ def _parse_date_time_time_zone(self, date_time_time_zone, is_all_day=False):
430430
local_tz = self.protocol.timezone
431431
if isinstance(date_time_time_zone, dict):
432432
try:
433-
timezone = ZoneInfo(
434-
get_iana_tz(date_time_time_zone.get(self._cc('timeZone'), 'UTC')))
433+
timezone = get_iana_tz(date_time_time_zone.get(self._cc('timeZone'), 'UTC'))
435434
except ZoneInfoNotFoundError:
436435
timezone = local_tz
437436
date_time = date_time_time_zone.get(self._cc('dateTime'), None)
@@ -461,11 +460,16 @@ def _build_date_time_time_zone(self, date_time):
461460
timezone = None
462461
if date_time.tzinfo is not None:
463462
if isinstance(date_time.tzinfo, ZoneInfo):
464-
timezone = date_time.tzinfo.key
463+
timezone = date_time.tzinfo
465464
elif isinstance(date_time.tzinfo, dt.tzinfo):
466-
timezone = date_time.tzinfo.tzname(date_time)
465+
try:
466+
timezone = ZoneInfo(date_time.tzinfo.tzname(date_time))
467+
except ZoneInfoNotFoundError as e:
468+
log.error(f'Error while converting datetime.tzinfo to Zoneinfo: '
469+
f'{date_time.tzinfo.tzname(date_time)}')
470+
raise e
467471
else:
468-
raise ValueError('Unexpected tzinfo class.')
472+
raise ValueError("Unexpected tzinfo class. Can't convert to ZoneInfo.")
469473

470474
timezone = get_windows_tz(timezone or self.protocol.timezone)
471475

O365/utils/windows_tz.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -605,27 +605,24 @@
605605
WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items() if v != 'UTC' or (v == 'UTC' and k == 'UTC')}
606606

607607

608-
def get_iana_tz(windows_tz):
608+
def get_iana_tz(windows_tz: str) -> ZoneInfo:
609609
""" Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given
610610
windows TimeZone
611611
612612
:param windows_tz: windows format timezone usually returned by
613613
microsoft api response
614-
:return:
615-
:rtype:
616614
"""
617-
timezone = WIN_TO_IANA.get(windows_tz)
615+
timezone: str = WIN_TO_IANA.get(windows_tz)
618616
if timezone is None:
619617
# Nope, that didn't work. Try adding "Standard Time",
620618
# it seems to work a lot of times:
621619
timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time')
622620

623621
# Return what we have.
624622
if timezone is None:
625-
raise ZoneInfoNotFoundError(
626-
"Can't find Windows TimeZone " + windows_tz)
623+
raise ZoneInfoNotFoundError(f"Can't find Windows TimeZone: {windows_tz}")
627624

628-
return timezone
625+
return ZoneInfo(timezone)
629626

630627

631628
def get_windows_tz(iana_tz: ZoneInfo) -> str:
@@ -637,6 +634,6 @@ def get_windows_tz(iana_tz: ZoneInfo) -> str:
637634
timezone = IANA_TO_WIN.get(
638635
iana_tz.key if isinstance(iana_tz, tzinfo) else iana_tz)
639636
if timezone is None:
640-
raise ZoneInfoNotFoundError(f"Can't find Iana timezone {iana_tz.key}")
637+
raise ZoneInfoNotFoundError(f"Can't find Iana timezone: {iana_tz.key}")
641638

642639
return timezone

0 commit comments

Comments
 (0)