Skip to content

Commit fd05171

Browse files
authored
Merge pull request #217 from minrk/utc-timestamps
ensure timestamps are timezone-aware
2 parents 7c5c013 + dc9e644 commit fd05171

File tree

7 files changed

+71
-45
lines changed

7 files changed

+71
-45
lines changed

jupyter_client/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
version_info = (4, 5, 0, 'dev')
1+
version_info = (5, 0, 0, 'dev')
22
__version__ = '.'.join(map(str, version_info))
33

44
protocol_version_info = (5, 0)

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: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
# coding: utf-8
12
"""Utilities to manipulate JSON objects."""
23

34
# Copyright (c) Jupyter Development Team.
45
# Distributed under the terms of the Modified BSD License.
56

6-
import re
77
from datetime import datetime
8+
import re
9+
import warnings
810

11+
from dateutil.parser import parse as _dateutil_parse
12+
from dateutil.tz import tzlocal
913

1014
from ipython_genutils import py3compat
1115
from ipython_genutils.py3compat import string_types, iteritems
@@ -17,7 +21,7 @@
1721

1822
# timestamp formats
1923
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})?$")
24+
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}))?$")
2125

2226
# holy crap, strptime is not threadsafe.
2327
# Calling it once at import seems to help.
@@ -27,6 +31,19 @@
2731
# Classes and functions
2832
#-----------------------------------------------------------------------------
2933

34+
def _ensure_tzinfo(dt):
35+
"""Ensure a datetime object has tzinfo
36+
37+
If no tzinfo is present, add tzlocal
38+
"""
39+
if not dt.tzinfo:
40+
# No more naïve datetime objects!
41+
warnings.warn(u"Interpreting naive datetime as local %s. Please add timezone info to timestamps." % dt,
42+
DeprecationWarning,
43+
stacklevel=4)
44+
dt = dt.replace(tzinfo=tzlocal())
45+
return dt
46+
3047
def parse_date(s):
3148
"""parse an ISO8601 date string
3249
@@ -38,13 +55,8 @@ def parse_date(s):
3855
return s
3956
m = ISO8601_PAT.match(s)
4057
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)
58+
dt = _dateutil_parse(s)
59+
return _ensure_tzinfo(dt)
4860
return s
4961

5062
def extract_dates(obj):
@@ -75,7 +87,8 @@ def squash_dates(obj):
7587
def date_default(obj):
7688
"""default function for packing datetime objects in JSON."""
7789
if isinstance(obj, datetime):
78-
return obj.isoformat()
90+
obj = _ensure_tzinfo(obj)
91+
return obj.isoformat().replace('+00:00', 'Z')
7992
else:
80-
raise TypeError("%r is not JSON serializable"%obj)
93+
raise TypeError("%r is not JSON serializable" % obj)
8194

jupyter_client/session.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
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+
# Python 2
51+
from dateutil.tz import tzutc
52+
utc = tzutc()
53+
4654
import zmq
4755
from zmq.utils import jsonapi
4856
from zmq.eventloop.ioloop import IOLoop
@@ -158,6 +166,9 @@ def default_secure(cfg):
158166
# key/keyfile not specified, generate new UUID:
159167
cfg.Session.key = new_id_bytes()
160168

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

162173
#-----------------------------------------------------------------------------
163174
# Classes
@@ -223,7 +234,8 @@ def __getitem__(self, k):
223234

224235

225236
def msg_header(msg_id, msg_type, username, session):
226-
date = datetime.now()
237+
"""Create a new message header"""
238+
date = utcnow()
227239
version = protocol_version
228240
return locals()
229241

@@ -519,7 +531,7 @@ def _check_packers(self):
519531
)
520532

521533
# check datetime support
522-
msg = dict(t=datetime.now())
534+
msg = dict(t=utcnow())
523535
try:
524536
unpacked = unpack(pack(msg))
525537
if isinstance(unpacked['t'], datetime):

jupyter_client/tests/test_jsonutil.py

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

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

11+
try:
12+
from unittest import mock
13+
except ImportError:
14+
# py2
15+
import mock
16+
1017
import nose.tools as nt
1118

19+
from dateutil.tz import tzlocal, tzoffset
1220
from jupyter_client import jsonutil
13-
from ipython_genutils.py3compat import unicode_to_str, str_to_bytes, iteritems
21+
from jupyter_client.session import utcnow
1422

1523

1624
def test_extract_dates():
1725
timestamps = [
1826
'2013-07-03T16:34:52.249482',
1927
'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',
2428
'2013-07-03T16:34:52.249482-0800',
2529
'2013-07-03T16:34:52.249482+0800',
26-
'2013-07-03T16:34:52.249482+08:00',
2730
'2013-07-03T16:34:52.249482-08:00',
31+
'2013-07-03T16:34:52.249482+08:00',
2832
]
2933
extracted = jsonutil.extract_dates(timestamps)
3034
ref = extracted[0]
3135
for dt in extracted:
3236
nt.assert_true(isinstance(dt, datetime.datetime))
33-
nt.assert_equal(dt, ref)
37+
nt.assert_not_equal(dt.tzinfo, None)
38+
39+
nt.assert_equal(extracted[0].tzinfo.utcoffset(ref), tzlocal().utcoffset(ref))
40+
nt.assert_equal(extracted[1].tzinfo.utcoffset(ref), timedelta(0))
41+
nt.assert_equal(extracted[2].tzinfo.utcoffset(ref), timedelta(hours=-8))
42+
nt.assert_equal(extracted[3].tzinfo.utcoffset(ref), timedelta(hours=8))
43+
nt.assert_equal(extracted[4].tzinfo.utcoffset(ref), timedelta(hours=-8))
44+
nt.assert_equal(extracted[5].tzinfo.utcoffset(ref), timedelta(hours=8))
3445

3546
def test_parse_ms_precision():
3647
base = '2013-07-03T16:34:52'
@@ -47,27 +58,18 @@ def test_parse_ms_precision():
4758
nt.assert_is_instance(parsed, str)
4859

4960

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()
6261

6362
def test_date_default():
64-
now = datetime.datetime.now()
65-
utcnow = now.replace(tzinfo=UTC)
66-
data = dict(now=now, utcnow=utcnow)
67-
jsondata = json.dumps(data, default=jsonutil.date_default)
68-
nt.assert_in("+00", jsondata)
69-
nt.assert_equal(jsondata.count("+00"), 1)
63+
naive = datetime.datetime.now()
64+
local = tzoffset('Local', -8 * 3600)
65+
other = tzoffset('Other', 2 * 3600)
66+
data = dict(naive=naive, utc=utcnow(), withtz=naive.replace(tzinfo=other))
67+
with mock.patch.object(jsonutil, 'tzlocal', lambda : local):
68+
jsondata = json.dumps(data, default=jsonutil.date_default)
69+
nt.assert_in("Z", jsondata)
70+
nt.assert_equal(jsondata.count("Z"), 1)
7071
extracted = jsonutil.extract_dates(json.loads(jsondata))
7172
for dt in extracted.values():
7273
nt.assert_is_instance(dt, datetime.datetime)
74+
nt.assert_not_equal(dt.tzinfo, None)
7375

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)