Skip to content

Commit 1667a43

Browse files
authored
UTCDateTimeAttribute now requires the date string format '%Y-%m-%dT%H:%M:%S.%f%z'. (#850)
1 parent d713ee1 commit 1667a43

File tree

5 files changed

+54
-99
lines changed

5 files changed

+54
-99
lines changed

docs/release_notes.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ This is major release and contains breaking changes. Please read the notes below
1313
This release introduces :ref:`polymorphism` support via :py:class:`DiscriminatorAttribute <pynamodb.attributes.DiscriminatorAttribute>`.
1414
Discriminator values are written to DynamoDB and used during deserialization to instantiate the desired class.
1515

16+
** UTCDateTimeAttribute **
17+
18+
The UTCDateTimeAttribute now strictly requires the date string format '%Y-%m-%dT%H:%M:%S.%f%z' to ensure proper ordering.
19+
PynamoDB has always written values with this format but previously would accept reading other formats.
20+
Items written using other formats must be rewritten before upgrading.
21+
1622
Other changes in this release:
1723

1824
* Python 2 is no longer supported. Python 3.6 or greater is now required.

pynamodb/attributes.py

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from datetime import datetime
1212
from datetime import timedelta
1313
from datetime import timezone
14-
from dateutil.parser import parse
1514
from inspect import getfullargspec
1615
from inspect import getmembers
1716
from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, TypeVar, Type, Union, Set, overload
@@ -668,16 +667,25 @@ def deserialize(self, value):
668667
"""
669668
Takes a UTC datetime string and returns a datetime object
670669
"""
670+
return self._fast_parse_utc_date_string(value)
671+
672+
@staticmethod
673+
def _fast_parse_utc_date_string(date_string: str) -> datetime:
674+
# Method to quickly parse strings formatted with '%Y-%m-%dT%H:%M:%S.%f+0000'.
675+
# This is ~5.8x faster than using strptime and 38x faster than dateutil.parser.parse.
676+
_int = int # Hack to prevent global lookups of int, speeds up the function ~10%
671677
try:
672-
return _fast_parse_utc_datestring(value)
673-
except (ValueError, IndexError):
674-
try:
675-
# Attempt to parse the datetime with the datetime format used
676-
# by default when storing UTCDateTimeAttributes. This is significantly
677-
# faster than always going through dateutil.
678-
return datetime.strptime(value, DATETIME_FORMAT)
679-
except ValueError:
680-
return parse(value)
678+
if (len(date_string) != 31 or date_string[4] != '-' or date_string[7] != '-'
679+
or date_string[10] != 'T' or date_string[13] != ':' or date_string[16] != ':'
680+
or date_string[19] != '.' or date_string[26:31] != '+0000'):
681+
raise ValueError("Datetime string '{}' does not match format '{}'".format(date_string, DATETIME_FORMAT))
682+
return datetime(
683+
_int(date_string[0:4]), _int(date_string[5:7]), _int(date_string[8:10]),
684+
_int(date_string[11:13]), _int(date_string[14:16]), _int(date_string[17:19]),
685+
_int(date_string[20:26]), timezone.utc
686+
)
687+
except (TypeError, ValueError):
688+
raise ValueError("Datetime string '{}' does not match format '{}'".format(date_string, DATETIME_FORMAT))
681689

682690

683691
class NullAttribute(Attribute[None]):
@@ -970,26 +978,6 @@ def _get_class_for_serialize(value):
970978
return SERIALIZE_CLASS_MAP[value_type]
971979

972980

973-
def _fast_parse_utc_datestring(datestring):
974-
# Method to quickly parse strings formatted with '%Y-%m-%dT%H:%M:%S.%f+0000'.
975-
# This is ~5.8x faster than using strptime and 38x faster than dateutil.parser.parse.
976-
_int = int # Hack to prevent global lookups of int, speeds up the function ~10%
977-
try:
978-
if (datestring[4] != '-' or datestring[7] != '-' or datestring[10] != 'T' or
979-
datestring[13] != ':' or datestring[16] != ':' or datestring[19] != '.' or
980-
datestring[-5:] != '+0000'):
981-
raise ValueError("Datetime string '{}' does not match format "
982-
"'%Y-%m-%dT%H:%M:%S.%f+0000'".format(datestring))
983-
return datetime(
984-
_int(datestring[0:4]), _int(datestring[5:7]), _int(datestring[8:10]),
985-
_int(datestring[11:13]), _int(datestring[14:16]), _int(datestring[17:19]),
986-
_int(round(float(datestring[19:-5]) * 1e6)), timezone.utc
987-
)
988-
except (TypeError, ValueError):
989-
raise ValueError("Datetime string '{}' does not match format "
990-
"'%Y-%m-%dT%H:%M:%S.%f+0000'".format(datestring))
991-
992-
993981
class ListAttribute(Generic[_T], Attribute[List[_T]]):
994982
attr_type = LIST
995983
element_type: Optional[Type[Attribute]] = None

requirements-dev.txt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ pytest>=5
22
pytest-env
33
pytest-mock
44

5-
# Due to https://github.com/boto/botocore/issues/1872. Remove after botocore fixes.
6-
python-dateutil==2.8.0
7-
85
# only used in .travis.yml
96
coveralls
107
mypy==0.770;python_version>="3.7"

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
install_requires = [
55
'botocore>=1.12.54',
6-
'python-dateutil>=2.1,<3.0.0',
76
]
87

98
setup(

tests/test_attributes.py

Lines changed: 30 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
from pynamodb.attributes import (
1515
BinarySetAttribute, BinaryAttribute, NumberSetAttribute, NumberAttribute,
1616
UnicodeAttribute, UnicodeSetAttribute, UTCDateTimeAttribute, BooleanAttribute, MapAttribute,
17-
ListAttribute, JSONAttribute, TTLAttribute, _fast_parse_utc_datestring,
18-
VersionAttribute)
17+
ListAttribute, JSONAttribute, TTLAttribute, VersionAttribute)
1918
from pynamodb.constants import (
20-
DATETIME_FORMAT, DEFAULT_ENCODING, NUMBER, STRING, STRING_SET, NUMBER_SET, BINARY_SET,
19+
DEFAULT_ENCODING, NUMBER, STRING, STRING_SET, NUMBER_SET, BINARY_SET,
2120
BINARY, BOOLEAN,
2221
)
2322
from pynamodb.models import Model
@@ -128,87 +127,53 @@ class TestUTCDateTimeAttribute:
128127
"""
129128
Tests UTCDateTime attributes
130129
"""
130+
131+
def setup(self):
132+
self.attr = UTCDateTimeAttribute()
133+
self.dt = datetime(2047, 1, 6, 8, 21, 30, 2000, tzinfo=timezone.utc)
134+
131135
def test_utc_datetime_attribute(self):
132136
"""
133137
UTCDateTimeAttribute.default
134138
"""
135-
attr = UTCDateTimeAttribute()
136-
assert attr is not None
139+
attr = UTCDateTimeAttribute(default=self.dt)
137140
assert attr.attr_type == STRING
138-
tstamp = datetime.now()
139-
attr = UTCDateTimeAttribute(default=tstamp)
140-
assert attr.default == tstamp
141-
142-
def test_utc_date_time_deserialize(self):
143-
"""
144-
UTCDateTimeAttribute.deserialize
145-
"""
146-
tstamp = datetime.now(timezone.utc)
147-
attr = UTCDateTimeAttribute()
148-
assert attr.deserialize(tstamp.strftime(DATETIME_FORMAT)) == tstamp
141+
assert attr.default == self.dt
149142

150-
def test_dateutil_parser_fallback(self):
143+
def test_utc_date_time_serialize(self):
151144
"""
152-
UTCDateTimeAttribute.deserialize
145+
UTCDateTimeAttribute.serialize
153146
"""
154-
expected_value = datetime(2047, 1, 6, 8, 21, tzinfo=timezone.utc)
155-
attr = UTCDateTimeAttribute()
156-
assert attr.deserialize('January 6, 2047 at 8:21:00AM UTC') == expected_value
147+
assert self.attr.serialize(self.dt) == '2047-01-06T08:21:30.002000+0000'
157148

158-
@patch('pynamodb.attributes.datetime')
159-
@patch('pynamodb.attributes.parse')
160-
def test_utc_date_time_deserialize_parse_args(self, parse_mock, datetime_mock):
149+
def test_utc_date_time_deserialize(self):
161150
"""
162151
UTCDateTimeAttribute.deserialize
163152
"""
164-
tstamp = datetime.now(timezone.utc)
165-
attr = UTCDateTimeAttribute()
166-
167-
tstamp_str = tstamp.strftime(DATETIME_FORMAT)
168-
attr.deserialize(tstamp_str)
169-
170-
parse_mock.assert_not_called()
171-
datetime_mock.strptime.assert_not_called()
172-
173-
def test_utc_date_time_serialize(self):
174-
"""
175-
UTCDateTimeAttribute.serialize
176-
"""
177-
tstamp = datetime.now()
178-
attr = UTCDateTimeAttribute()
179-
assert attr.serialize(tstamp) == tstamp.replace(tzinfo=timezone.utc).strftime(DATETIME_FORMAT)
180-
181-
def test__fast_parse_utc_datestring_roundtrips(self):
182-
tstamp = datetime.now(timezone.utc)
183-
tstamp_str = tstamp.strftime(DATETIME_FORMAT)
184-
assert _fast_parse_utc_datestring(tstamp_str) == tstamp
185-
186-
def test__fast_parse_utc_datestring_no_microseconds(self):
187-
expected_value = datetime(2047, 1, 6, 8, 21, tzinfo=timezone.utc)
188-
assert _fast_parse_utc_datestring('2047-01-06T08:21:00.0+0000') == expected_value
153+
assert self.attr.deserialize('2047-01-06T08:21:30.002000+0000') == self.dt
189154

190155
@pytest.mark.parametrize(
191156
"invalid_string",
192157
[
193-
'2.47-01-06T08:21:00.0+0000',
194-
'2047-01-06T08:21:00.+0000',
195-
'2047-01-06T08:21:00.0',
196-
'2047-01-06 08:21:00.0+0000',
197-
'abcd-01-06T08:21:00.0+0000',
198-
'2047-ab-06T08:21:00.0+0000',
199-
'2047-01-abT08:21:00.0+0000',
200-
'2047-01-06Tab:21:00.0+0000',
201-
'2047-01-06T08:ab:00.0+0000',
202-
'2047-01-06T08:ab:00.0+0000',
203-
'2047-01-06T08:21:00.a+0000',
204-
'2047-01-06T08:21:00.0.1+0000',
205-
'2047-01-06T08:21:00.0+00000'
158+
'2047-01-06T08:21:30.002000', # naive datetime
159+
'2047-01-06T08:21:30+0000', # missing microseconds
160+
'2047-01-06T08:21:30.001+0000', # shortened microseconds
161+
'2047-01-06T08:21:30.002000-0000' # "negative" utc
162+
'2047-01-06T08:21:30.002000+0030' # not utc
163+
'2047-01-06 08:21:30.002000+0000', # missing separator
164+
'2.47-01-06T08:21:30.002000+0000',
165+
'abcd-01-06T08:21:30.002000+0000',
166+
'2047-ab-06T08:21:30.002000+0000',
167+
'2047-01-abT08:21:30.002000+0000',
168+
'2047-01-06Tab:21:30.002000+0000',
169+
'2047-01-06T08:ab:30.002000+0000',
170+
'2047-01-06T08:21:ab.002000+0000',
171+
'2047-01-06T08:21:30.a00000+0000',
206172
]
207173
)
208-
def test__fast_parse_utc_datestring_invalid_input(self, invalid_string):
174+
def test_utc_date_time_invalid(self, invalid_string):
209175
with pytest.raises(ValueError, match="does not match format"):
210-
_fast_parse_utc_datestring(invalid_string)
211-
176+
self.attr.deserialize(invalid_string)
212177

213178

214179
class TestBinaryAttribute:

0 commit comments

Comments
 (0)