Skip to content

Commit 871cc23

Browse files
Merge pull request #88 from singingwolfboy/datetime-attrs
Convert timestamp fields to datetime fields
2 parents aff310a + a11c56b commit 871cc23

9 files changed

Lines changed: 222 additions & 25 deletions

File tree

examples/webhooks/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def process_delta(delta):
129129
"""
130130
kwargs = {
131131
"type": delta["type"],
132-
"date": datetime.datetime.fromtimestamp(delta["date"]),
132+
"date": datetime.datetime.utcfromtimestamp(delta["date"]),
133133
"object_id": delta["object_data"]["id"],
134134
}
135135
print(" * {type} at {date} with ID {object_id}".format(**kwargs))

nylas/client/client.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from __future__ import print_function
22
import sys
3-
import json
43
from os import environ
54
from base64 import b64encode
5+
import json
6+
try:
7+
from json import JSONDecodeError
8+
except ImportError:
9+
JSONDecodeError = ValueError
10+
611
import requests
712
from urlobject import URLObject
813
from six.moves.urllib.parse import urlencode
@@ -14,10 +19,7 @@
1419
Label, Draft
1520
)
1621
from nylas.client.errors import APIClientError, ConnectionError, STATUS_MAP
17-
try:
18-
from json import JSONDecodeError
19-
except ImportError:
20-
JSONDecodeError = ValueError
22+
from nylas.utils import convert_datetimes_to_timestamps
2123

2224
DEBUG = environ.get('NYLAS_CLIENT_DEBUG')
2325
API_SERVER = "https://api.nylas.com"
@@ -256,7 +258,10 @@ def _get_resources(self, cls, extra=None, **filters):
256258
postfix
257259
)
258260

259-
url = str(URLObject(url).add_query_params(filters.items()))
261+
converted_filters = convert_datetimes_to_timestamps(
262+
filters, cls.datetime_filter_attrs,
263+
)
264+
url = str(URLObject(url).add_query_params(converted_filters.items()))
260265
response = self._get_http_session(cls.api_root).get(url)
261266
results = _validate(response).json()
262267
return [
@@ -280,7 +285,10 @@ def _get_resource_raw(self, cls, id, extra=None,
280285
url = "{}/a/{}/{}/{}{}".format(self.api_server, self.app_id,
281286
cls.collection_name, id, postfix)
282287

283-
url = str(URLObject(url).add_query_params(filters.items()))
288+
converted_filters = convert_datetimes_to_timestamps(
289+
filters, cls.datetime_filter_attrs,
290+
)
291+
url = str(URLObject(url).add_query_params(converted_filters.items()))
284292

285293
response = self._get_http_session(cls.api_root).get(url, headers=headers)
286294
return _validate(response)
@@ -311,10 +319,10 @@ def _create_resource(self, cls, data, **kwargs):
311319
if cls == File:
312320
response = session.post(url, files=data)
313321
else:
314-
data = json.dumps(data)
322+
converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
315323
headers = {'Content-Type': 'application/json'}
316324
headers.update(self.session.headers)
317-
response = session.post(url, data=data, headers=headers)
325+
response = session.post(url, json=converted_data, headers=headers)
318326

319327
result = _validate(response).json()
320328
if cls.collection_name == 'send':
@@ -332,10 +340,13 @@ def _create_resources(self, cls, data):
332340
if cls == File:
333341
response = session.post(url, files=data)
334342
else:
335-
data = json.dumps(data)
343+
converted_data = [
344+
convert_datetimes_to_timestamps(datum, cls.datetime_attrs)
345+
for datum in data
346+
]
336347
headers = {'Content-Type': 'application/json'}
337348
headers.update(self.session.headers)
338-
response = session.post(url, data=data, headers=headers)
349+
response = session.post(url, json=converted_data, headers=headers)
339350

340351
results = _validate(response).json()
341352
return [cls.create(self, **x) for x in results]
@@ -363,7 +374,8 @@ def _update_resource(self, cls, id, data, **kwargs):
363374

364375
session = self._get_http_session(cls.api_root)
365376

366-
response = session.put(url, json=data)
377+
converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
378+
response = session.put(url, json=converted_data)
367379

368380
result = _validate(response).json()
369381
return cls.create(self, **result)
@@ -390,9 +402,10 @@ def _call_resource_method(self, cls, id, method_name, data):
390402
URLObject(self.api_server)
391403
.with_path(url_path)
392404
)
405+
converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
393406

394407
session = self._get_http_session(cls.api_root)
395-
response = session.post(url, json=data)
408+
response = session.post(url, json=converted_data)
396409

397410
result = _validate(response).json()
398411
return cls.create(self, **result)

nylas/client/restful_models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
from datetime import datetime
2+
13
from nylas.client.restful_model_collection import RestfulModelCollection
24
from nylas.client.errors import FileUploadError
5+
from nylas.utils import timestamp_from_dt
36
from six import StringIO
47

58
# pylint: disable=attribute-defined-outside-init
69

710

811
class NylasAPIObject(dict):
912
attrs = []
13+
datetime_attrs = {}
14+
datetime_filter_attrs = {}
1015
# The Nylas API holds most objects for an account directly under '/',
1116
# but some of them are under '/a' (mostly the account-management
1217
# and billing code). api_root is a tiny metaprogramming hack to let
@@ -44,6 +49,9 @@ def create(cls, api, **kwargs):
4449
attr = attr_name[1:]
4550
if attr in kwargs:
4651
obj[attr_name] = kwargs[attr]
52+
for dt_attr, ts_attr in cls.datetime_attrs.items():
53+
if obj.get(ts_attr):
54+
obj[dt_attr] = datetime.utcfromtimestamp(obj[ts_attr])
4755
if 'id' not in kwargs:
4856
obj['id'] = None
4957

@@ -54,6 +62,9 @@ def as_json(self):
5462
for attr in self.cls.attrs:
5563
if hasattr(self, attr):
5664
dct[attr] = getattr(self, attr)
65+
for dt_attr, ts_attr in self.cls.datetime_attrs.items():
66+
if self.get(dt_attr):
67+
dct[ts_attr] = timestamp_from_dt(self[dt_attr])
5768
return dct
5869

5970
def child_collection(self, cls, **filters):
@@ -83,6 +94,13 @@ class Message(NylasAPIObject):
8394
"account_id", "object", "snippet", "starred", "subject",
8495
"thread_id", "to", "unread", "starred", "_folder", "_labels",
8596
"headers"]
97+
datetime_attrs = {
98+
"received_at": "date",
99+
}
100+
datetime_filter_attrs = {
101+
"received_before": "received_before",
102+
"received_after": "received_after",
103+
}
86104
collection_name = 'messages'
87105

88106
def __init__(self, api):
@@ -206,8 +224,21 @@ class Thread(NylasAPIObject):
206224
attrs = ["draft_ids", "id", "message_ids", "account_id", "object",
207225
"participants", "snippet", "subject", "subject_date",
208226
"last_message_timestamp", "first_message_timestamp",
227+
"last_message_received_timestamp", "last_message_sent_timestamp",
209228
"unread", "starred", "version", "_folders", "_labels",
210229
"received_recent_date"]
230+
datetime_attrs = {
231+
"first_message_at": "first_message_timestamp",
232+
"last_message_at": "last_message_timestamp",
233+
"last_message_received_at": "last_message_received_timestamp",
234+
"last_message_sent_at": "last_message_sent_timestamp",
235+
}
236+
datetime_filter_attrs = {
237+
"last_message_before": "last_message_before",
238+
"last_message_after": "last_message_after",
239+
"started_before": "started_before",
240+
"started_after": "started_after",
241+
}
211242
collection_name = 'threads'
212243

213244
def __init__(self, api):
@@ -313,6 +344,9 @@ class Draft(Message):
313344
"account_id", "object", "subject", "thread_id", "to",
314345
"unread", "version", "file_ids", "reply_to_message_id",
315346
"reply_to", "starred", "snippet", "tracking"]
347+
datetime_attrs = {
348+
"last_modified_at": "date",
349+
}
316350
collection_name = 'drafts'
317351

318352
def __init__(self, api, thread_id=None): # pylint: disable=unused-argument
@@ -407,6 +441,9 @@ class Event(NylasAPIObject):
407441
"read_only", "when", "busy", "participants", "calendar_id",
408442
"recurrence", "status", "master_event_id", "owner",
409443
"original_start_time", "object", "message_id"]
444+
datetime_attrs = {
445+
"original_start_at": "original_start_time",
446+
}
410447
collection_name = 'events'
411448

412449
def __init__(self, api):

nylas/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import division
2+
from datetime import datetime
3+
4+
5+
def timestamp_from_dt(dt, epoch=datetime(1970, 1, 1)):
6+
"""
7+
Convert a datetime to a timestamp.
8+
https://stackoverflow.com/a/8778548/141395
9+
"""
10+
delta = dt - epoch
11+
# return delta.total_seconds()
12+
return delta.seconds + delta.days * 86400
13+
14+
15+
def convert_datetimes_to_timestamps(data, datetime_attrs):
16+
"""
17+
Given a dictionary of data, and a dictionary of datetime attributes,
18+
return a new dictionary that converts any datetime attributes that may
19+
be present to their timestamped equivalent.
20+
"""
21+
if not data:
22+
return data
23+
24+
new_data = {}
25+
for key, value in data.items():
26+
if key in datetime_attrs and isinstance(value, datetime):
27+
new_key = datetime_attrs[key]
28+
new_data[new_key] = timestamp_from_dt(value)
29+
else:
30+
new_data[key] = value
31+
32+
return new_data

pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
156156
function-rgx=(([a-z][a-z0-9_]{2,50})|(_[a-z0-9_]*))$
157157

158158
# Good variable names which should always be accepted, separated by a comma
159-
good-names=i,j,k,ex,Run,_,id
159+
good-names=i,j,k,ex,Run,_,id,dt,db
160160

161161
# Include a hint for the correct naming format with invalid-name
162162
include-naming-hint=no

tests/conftest.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,8 @@ def mock_messages(mocked_responses, api_url, account_id):
264264
}
265265
],
266266
"starred": False,
267-
"unread": True
267+
"unread": True,
268+
"date": 1265077342,
268269
}, {
269270
"id": "1238",
270271
"subject": "Test Message 2",
@@ -278,7 +279,8 @@ def mock_messages(mocked_responses, api_url, account_id):
278279
}
279280
],
280281
"starred": False,
281-
"unread": True
282+
"unread": True,
283+
"date": 1265085342,
282284
}, {
283285
"id": "12",
284286
"subject": "Test Message 3",
@@ -292,7 +294,8 @@ def mock_messages(mocked_responses, api_url, account_id):
292294
}
293295
],
294296
"starred": False,
295-
"unread": False
297+
"unread": False,
298+
"date": 1265093842,
296299
}
297300
])
298301
endpoint = re.compile(api_url + '/messages')
@@ -369,7 +372,11 @@ def mock_threads(mocked_responses, api_url, account_id):
369372
"id": "abcd"
370373
}],
371374
"starred": True,
372-
"unread": False
375+
"unread": False,
376+
"first_message_timestamp": 1451703845,
377+
"last_message_timestamp": 1483326245,
378+
"last_message_received_timestamp": 1483326245,
379+
"last_message_sent_timestamp": 1483232461,
373380
}
374381
])
375382
endpoint = re.compile(api_url + '/threads')
@@ -395,7 +402,11 @@ def mock_thread(mocked_responses, api_url, account_id):
395402
"id": "abcd"
396403
}],
397404
"starred": True,
398-
"unread": False
405+
"unread": False,
406+
"first_message_timestamp": 1451703845,
407+
"last_message_timestamp": 1483326245,
408+
"last_message_received_timestamp": 1483326245,
409+
"last_message_sent_timestamp": 1483232461,
399410
}
400411
response_body = json.dumps(base_thrd)
401412

@@ -451,7 +462,11 @@ def mock_labelled_thread(mocked_responses, api_url, account_id):
451462
"account_id": account_id,
452463
"object": "label"
453464
}
454-
]
465+
],
466+
"first_message_timestamp": 1451703845,
467+
"last_message_timestamp": 1483326245,
468+
"last_message_received_timestamp": 1483326245,
469+
"last_message_sent_timestamp": 1483232461,
455470
}
456471
response_body = json.dumps(base_thread)
457472

@@ -881,10 +896,17 @@ def mock_events(mocked_responses, api_url):
881896
"title": "Pool party",
882897
"location": "Local Community Pool",
883898
"participants": [
884-
"Alice",
885-
"Bob",
886-
"Claire",
887-
"Dot",
899+
{
900+
"comment": None,
901+
"email": "kelly@nylas.com",
902+
"name": "Kelly Nylanaut",
903+
"status": "noreply",
904+
}, {
905+
"comment": None,
906+
"email": "sarah@nylas.com",
907+
"name": "Sarah Nylanaut",
908+
"status": "no",
909+
},
888910
]
889911
}
890912
])

tests/test_drafts.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
from datetime import datetime
2+
13
import pytest
24
from nylas.client.errors import InvalidRequestError
5+
from nylas.utils import timestamp_from_dt
36

47
# pylint: disable=len-as-condition
58

69

10+
@pytest.mark.usefixtures("mock_drafts")
11+
def test_draft_attrs(api_client):
12+
draft = api_client.drafts.first()
13+
expected_modified = datetime(2015, 8, 4, 10, 34, 46)
14+
assert draft.last_modified_at == expected_modified
15+
assert draft.date == timestamp_from_dt(expected_modified)
16+
17+
718
@pytest.mark.usefixtures(
819
"mock_draft_saved_response", "mock_draft_sent_response"
920
)

0 commit comments

Comments
 (0)