Skip to content

Commit f0ae719

Browse files
committed
ensure timestamps are timezone-aware
- use UTC timestamps everywhere - use common Z format instead of +00:00 for UTC - naïve timestamps are given local timezone on serialize/deserialize - use python-dateutil for parsing, local timezone
1 parent b76a9f6 commit f0ae719

File tree

6 files changed

+53
-42
lines changed

6 files changed

+53
-42
lines changed

jupyter_client/adapter.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@
66
import re
77
import json
88

9-
from datetime import datetime
10-
119
from jupyter_client import protocol_version_info
1210

13-
1411
def code_to_line(code, cursor_pos):
1512
"""Turn a multiline code block and cursor position into a single line
1613
and new cursor position.
@@ -386,9 +383,10 @@ def adapt(msg, to_version=protocol_version_info[0]):
386383
msg : dict
387384
A Jupyter message appropriate in the new version.
388385
"""
386+
from .session import utcnow
389387
header = msg['header']
390388
if 'date' not in header:
391-
header['date'] = datetime.now().isoformat()
389+
header['date'] = utcnow()
392390
if 'version' in header:
393391
from_version = int(header['version'].split('.')[0])
394392
else:

jupyter_client/jsonutil.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import re
77
from datetime import datetime
88

9+
from dateutil.parser import parse as _dateutil_parse
10+
from dateutil.tz import tzlocal
911

1012
from ipython_genutils import py3compat
1113
from ipython_genutils.py3compat import string_types, iteritems
@@ -17,7 +19,7 @@
1719

1820
# timestamp formats
1921
ISO8601 = "%Y-%m-%dT%H:%M:%S.%f"
20-
ISO8601_PAT=re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,6})?Z?([\+\-]\d{2}:?\d{2})?$")
22+
ISO8601_PAT = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,6})?(Z|([\+\-]\d{2}:?\d{2}))?$")
2123

2224
# holy crap, strptime is not threadsafe.
2325
# Calling it once at import seems to help.
@@ -27,6 +29,16 @@
2729
# Classes and functions
2830
#-----------------------------------------------------------------------------
2931

32+
def _ensure_tzinfo(dt):
33+
"""Ensure a datetime object has tzinfo
34+
35+
If no tzinfo is present, add tzlocal
36+
"""
37+
if not dt.tzinfo:
38+
# No more naïve datetime objects!
39+
dt = dt.replace(tzinfo=tzlocal())
40+
return dt
41+
3042
def parse_date(s):
3143
"""parse an ISO8601 date string
3244
@@ -38,13 +50,8 @@ def parse_date(s):
3850
return s
3951
m = ISO8601_PAT.match(s)
4052
if m:
41-
# FIXME: add actual timezone support
42-
# this just drops the timezone info
43-
notz, ms, tz = m.groups()
44-
if not ms:
45-
ms = '.0'
46-
notz = notz + ms
47-
return datetime.strptime(notz, ISO8601)
53+
dt = _dateutil_parse(s)
54+
return _ensure_tzinfo(dt)
4855
return s
4956

5057
def extract_dates(obj):
@@ -75,7 +82,8 @@ def squash_dates(obj):
7582
def date_default(obj):
7683
"""default function for packing datetime objects in JSON."""
7784
if isinstance(obj, datetime):
78-
return obj.isoformat()
85+
obj = _ensure_tzinfo(obj)
86+
return obj.isoformat().replace('+00:00', 'Z')
7987
else:
80-
raise TypeError("%r is not JSON serializable"%obj)
88+
raise TypeError("%r is not JSON serializable" % obj)
8189

jupyter_client/session.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
# limiting the surface of attack
4444
def compare_digest(a,b): return a == b
4545

46+
try:
47+
from datetime import timezone
48+
utc = timezone.utc
49+
except ImportError:
50+
from dateutil.tz import tzutc
51+
utc = tzutc()
52+
4653
import zmq
4754
from zmq.utils import jsonapi
4855
from zmq.eventloop.ioloop import IOLoop
@@ -158,6 +165,9 @@ def default_secure(cfg):
158165
# key/keyfile not specified, generate new UUID:
159166
cfg.Session.key = new_id_bytes()
160167

168+
def utcnow():
169+
"""Return timezone-aware UTC timestamp"""
170+
return datetime.utcnow().replace(tzinfo=utc)
161171

162172
#-----------------------------------------------------------------------------
163173
# Classes
@@ -223,7 +233,8 @@ def __getitem__(self, k):
223233

224234

225235
def msg_header(msg_id, msg_type, username, session):
226-
date = datetime.now()
236+
"""Create a new message header"""
237+
date = utcnow()
227238
version = protocol_version
228239
return locals()
229240

@@ -519,7 +530,7 @@ def _check_packers(self):
519530
)
520531

521532
# check datetime support
522-
msg = dict(t=datetime.now())
533+
msg = dict(t=utcnow())
523534
try:
524535
unpacked = unpack(pack(msg))
525536
if isinstance(unpacked['t'], datetime):

jupyter_client/tests/test_jsonutil.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,37 @@
55
# Distributed under the terms of the Modified BSD License.
66

77
import datetime
8+
from datetime import timedelta
89
import json
910

1011
import nose.tools as nt
1112

13+
from dateutil.tz import tzlocal, gettz
1214
from jupyter_client import jsonutil
13-
from ipython_genutils.py3compat import unicode_to_str, str_to_bytes, iteritems
15+
from jupyter_client.session import utcnow
1416

1517

1618
def test_extract_dates():
1719
timestamps = [
1820
'2013-07-03T16:34:52.249482',
1921
'2013-07-03T16:34:52.249482Z',
20-
'2013-07-03T16:34:52.249482Z-0800',
21-
'2013-07-03T16:34:52.249482Z+0800',
22-
'2013-07-03T16:34:52.249482Z+08:00',
23-
'2013-07-03T16:34:52.249482Z-08:00',
2422
'2013-07-03T16:34:52.249482-0800',
2523
'2013-07-03T16:34:52.249482+0800',
26-
'2013-07-03T16:34:52.249482+08:00',
2724
'2013-07-03T16:34:52.249482-08:00',
25+
'2013-07-03T16:34:52.249482+08:00',
2826
]
2927
extracted = jsonutil.extract_dates(timestamps)
3028
ref = extracted[0]
3129
for dt in extracted:
3230
nt.assert_true(isinstance(dt, datetime.datetime))
33-
nt.assert_equal(dt, ref)
31+
nt.assert_not_equal(dt.tzinfo, None)
32+
33+
nt.assert_equal(extracted[0].tzinfo.utcoffset(ref), tzlocal().utcoffset(ref))
34+
nt.assert_equal(extracted[1].tzinfo.utcoffset(ref), timedelta(0))
35+
nt.assert_equal(extracted[2].tzinfo.utcoffset(ref), timedelta(hours=-8))
36+
nt.assert_equal(extracted[3].tzinfo.utcoffset(ref), timedelta(hours=8))
37+
nt.assert_equal(extracted[4].tzinfo.utcoffset(ref), timedelta(hours=-8))
38+
nt.assert_equal(extracted[5].tzinfo.utcoffset(ref), timedelta(hours=8))
3439

3540
def test_parse_ms_precision():
3641
base = '2013-07-03T16:34:52'
@@ -47,27 +52,15 @@ def test_parse_ms_precision():
4752
nt.assert_is_instance(parsed, str)
4853

4954

50-
ZERO = datetime.timedelta(0)
51-
52-
class tzUTC(datetime.tzinfo):
53-
"""tzinfo object for UTC (zero offset)"""
54-
55-
def utcoffset(self, d):
56-
return ZERO
57-
58-
def dst(self, d):
59-
return ZERO
60-
61-
UTC = tzUTC()
6255

6356
def test_date_default():
64-
now = datetime.datetime.now()
65-
utcnow = now.replace(tzinfo=UTC)
66-
data = dict(now=now, utcnow=utcnow)
57+
naive = datetime.datetime.now()
58+
data = dict(naive=naive, utc=utcnow(), withtz=naive.replace(tzinfo=gettz('CEST')))
6759
jsondata = json.dumps(data, default=jsonutil.date_default)
68-
nt.assert_in("+00", jsondata)
69-
nt.assert_equal(jsondata.count("+00"), 1)
60+
nt.assert_in("Z", jsondata)
61+
nt.assert_equal(jsondata.count("Z"), 1)
7062
extracted = jsonutil.extract_dates(json.loads(jsondata))
7163
for dt in extracted.values():
7264
nt.assert_is_instance(dt, datetime.datetime)
65+
nt.assert_not_equal(dt.tzinfo, None)
7366

jupyter_client/tests/test_session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,8 @@ def test_bad_roundtrip(self):
258258
session = ss.Session(unpack=lambda b: 5)
259259

260260
def _datetime_test(self, session):
261-
content = dict(t=datetime.now())
262-
metadata = dict(t=datetime.now())
261+
content = dict(t=ss.utcnow())
262+
metadata = dict(t=ss.utcnow())
263263
p = session.msg('msg')
264264
msg = session.msg('msg', content=content, metadata=metadata, parent=p['header'])
265265
smsg = session.serialize(msg)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
'traitlets',
7979
'jupyter_core',
8080
'pyzmq>=13',
81+
'python-dateutil>=2.1',
8182
]
8283

8384
extras_require = setuptools_args['extras_require'] = {

0 commit comments

Comments
 (0)