Skip to content

Commit 17367ba

Browse files
author
Chris Rossi
authored
Add tzinfo to DateTimeProperty. (#226)
Fixes #7.
1 parent ddc8d7e commit 17367ba

File tree

4 files changed

+112
-11
lines changed

4 files changed

+112
-11
lines changed

packages/google-cloud-ndb/google/cloud/ndb/model.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ class Person(Model):
254254
import six
255255
import zlib
256256

257+
import pytz
258+
257259
from google.cloud.datastore import entity as ds_entity_module
258260
from google.cloud.datastore import helpers
259261
from google.cloud.datastore_v1.proto import entity_pb2
@@ -3467,9 +3469,12 @@ def _validate(self, value):
34673469
class DateTimeProperty(Property):
34683470
"""A property that contains :class:`~datetime.datetime` values.
34693471
3470-
This property expects "naive" datetime stamps, i.e. no timezone can
3471-
be set. Furthermore, the assumption is that naive datetime stamps
3472-
represent UTC.
3472+
If ``tzinfo`` is not set, this property expects "naive" datetime stamps,
3473+
i.e. no timezone can be set. Furthermore, the assumption is that naive
3474+
datetime stamps represent UTC.
3475+
3476+
If ``tzinfo`` is set, timestamps will be stored as UTC and converted back
3477+
to the timezone set by ``tzinfo`` when reading values back out.
34733478
34743479
.. note::
34753480
@@ -3493,6 +3498,9 @@ class DateTimeProperty(Property):
34933498
updated.
34943499
auto_now_add (bool): Indicates that the property should be set to the
34953500
current datetime when an entity is created.
3501+
tzinfo (Optional[datetime.tzinfo]): If set, values read from Datastore
3502+
will be converted to this timezone. Otherwise, values will be
3503+
returned as naive datetime objects with an implied UTC timezone.
34963504
indexed (bool): Indicates if the value should be indexed.
34973505
repeated (bool): Indicates if this property is repeated, i.e. contains
34983506
multiple values.
@@ -3514,13 +3522,15 @@ class DateTimeProperty(Property):
35143522

35153523
_auto_now = False
35163524
_auto_now_add = False
3525+
_tzinfo = None
35173526

35183527
def __init__(
35193528
self,
35203529
name=None,
35213530
*,
35223531
auto_now=None,
35233532
auto_now_add=None,
3533+
tzinfo=None,
35243534
indexed=None,
35253535
repeated=None,
35263536
required=None,
@@ -3556,6 +3566,8 @@ def __init__(
35563566
self._auto_now = auto_now
35573567
if auto_now_add is not None:
35583568
self._auto_now_add = auto_now_add
3569+
if tzinfo is not None:
3570+
self._tzinfo = tzinfo
35593571

35603572
def _validate(self, value):
35613573
"""Validate a ``value`` before setting it.
@@ -3571,10 +3583,10 @@ def _validate(self, value):
35713583
"Expected datetime, got {!r}".format(value)
35723584
)
35733585

3574-
if value.tzinfo is not None:
3586+
if self._tzinfo is None and value.tzinfo is not None:
35753587
raise exceptions.BadValueError(
3576-
"DatetimeProperty {} can only support naive datetimes "
3577-
"(presumed UTC). Please derive a new Property to support "
3588+
"DatetimeProperty without tzinfo {} can only support naive "
3589+
"datetimes (presumed UTC). Please set tzinfo to support "
35783590
"alternate timezones.".format(self._name)
35793591
)
35803592

@@ -3613,12 +3625,32 @@ def _from_base_type(self, value):
36133625
value (datetime.datetime): The value to be converted.
36143626
36153627
Returns:
3616-
Optional[datetime.datetime]: The value without ``tzinfo`` or
3617-
``None`` if value did not have ``tzinfo`` set.
3628+
Optional[datetime.datetime]: If ``tzinfo`` is set on this property,
3629+
the value converted to the timezone in ``tzinfo``. Otherwise
3630+
returns the value without ``tzinfo`` or ``None`` if value did
3631+
not have ``tzinfo`` set.
36183632
"""
3619-
if value.tzinfo is not None:
3633+
if self._tzinfo is not None:
3634+
return value.astimezone(self._tzinfo)
3635+
3636+
elif value.tzinfo is not None:
36203637
return value.replace(tzinfo=None)
36213638

3639+
def _to_base_type(self, value):
3640+
"""Convert a value to the "base" value type for this property.
3641+
3642+
Args:
3643+
value (datetime.datetime): The value to be converted.
3644+
3645+
Returns:
3646+
google.cloud.datastore.Key: The converted value.
3647+
3648+
Raises:
3649+
TypeError: If ``value`` is not a :class:`~key.Key`.
3650+
"""
3651+
if self._tzinfo is not None and value.tzinfo is not None:
3652+
return value.astimezone(pytz.utc)
3653+
36223654

36233655
class DateProperty(DateTimeProperty):
36243656
"""A property that contains :class:`~datetime.date` values.

packages/google-cloud-ndb/tests/system/test_crud.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,37 @@ class SomeKind(ndb.Model):
261261
dispose_of(key._key)
262262

263263

264+
@pytest.mark.usefixtures("client_context")
265+
def test_datetime_w_tzinfo(dispose_of, ds_client):
266+
class timezone(datetime.tzinfo):
267+
def __init__(self, offset):
268+
self.offset = datetime.timedelta(hours=offset)
269+
270+
def utcoffset(self, dt):
271+
return self.offset
272+
273+
def dst(self, dt):
274+
return datetime.timedelta(0)
275+
276+
mytz = timezone(-4)
277+
278+
class SomeKind(ndb.Model):
279+
foo = ndb.DateTimeProperty(tzinfo=mytz)
280+
bar = ndb.DateTimeProperty(tzinfo=mytz)
281+
282+
entity = SomeKind(
283+
foo=datetime.datetime(2010, 5, 12, 2, 42, tzinfo=timezone(-5)),
284+
bar=datetime.datetime(2010, 5, 12, 2, 42),
285+
)
286+
key = entity.put()
287+
288+
retrieved = key.get()
289+
assert retrieved.foo == datetime.datetime(2010, 5, 12, 3, 42, tzinfo=mytz)
290+
assert retrieved.bar == datetime.datetime(2010, 5, 11, 22, 42, tzinfo=mytz)
291+
292+
dispose_of(key._key)
293+
294+
264295
def test_parallel_threads(dispose_of, namespace):
265296
client = ndb.Client(namespace=namespace)
266297

@@ -337,7 +368,7 @@ class SomeKind(ndb.Model):
337368

338369
@pytest.mark.usefixtures("client_context")
339370
def test_retrieve_entity_with_legacy_compressed_property(
340-
ds_entity_with_meanings
371+
ds_entity_with_meanings,
341372
):
342373
class SomeKind(ndb.Model):
343374
blob = ndb.BlobProperty()

packages/google-cloud-ndb/tests/unit/test_model.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@
3838
from tests.unit import utils
3939

4040

41+
class timezone(datetime.tzinfo):
42+
def __init__(self, offset):
43+
self.offset = datetime.timedelta(hours=offset)
44+
45+
def utcoffset(self, dt):
46+
return self.offset
47+
48+
def dst(self, dt):
49+
return datetime.timedelta(0)
50+
51+
def __eq__(self, other):
52+
return self.offset == other.offset
53+
54+
4155
def test___all__():
4256
utils.verify___all__(model)
4357

@@ -2548,6 +2562,7 @@ def test_constructor_explicit():
25482562
name="dt_val",
25492563
auto_now=True,
25502564
auto_now_add=False,
2565+
tzinfo=timezone(-4),
25512566
indexed=False,
25522567
repeated=False,
25532568
required=True,
@@ -2559,6 +2574,7 @@ def test_constructor_explicit():
25592574
assert prop._name == "dt_val"
25602575
assert prop._auto_now
25612576
assert not prop._auto_now_add
2577+
assert prop._tzinfo == timezone(-4)
25622578
assert not prop._indexed
25632579
assert not prop._repeated
25642580
assert prop._required
@@ -2671,6 +2687,28 @@ def test__from_base_type_timezone():
26712687
value = datetime.datetime(2010, 5, 12, tzinfo=pytz.utc)
26722688
assert prop._from_base_type(value) == datetime.datetime(2010, 5, 12)
26732689

2690+
@staticmethod
2691+
def test__from_base_type_convert_timezone():
2692+
prop = model.DateTimeProperty(name="dt_val", tzinfo=timezone(-4))
2693+
value = datetime.datetime(2010, 5, 12, tzinfo=pytz.utc)
2694+
assert prop._from_base_type(value) == datetime.datetime(
2695+
2010, 5, 11, 20, tzinfo=timezone(-4)
2696+
)
2697+
2698+
@staticmethod
2699+
def test__to_base_type_noop():
2700+
prop = model.DateTimeProperty(name="dt_val", tzinfo=timezone(-4))
2701+
value = datetime.datetime(2010, 5, 12)
2702+
assert prop._to_base_type(value) is None
2703+
2704+
@staticmethod
2705+
def test__to_base_type_convert_to_utc():
2706+
prop = model.DateTimeProperty(name="dt_val", tzinfo=timezone(-4))
2707+
value = datetime.datetime(2010, 5, 12, tzinfo=timezone(-4))
2708+
assert prop._to_base_type(value) == datetime.datetime(
2709+
2010, 5, 12, 4, tzinfo=pytz.utc
2710+
)
2711+
26742712

26752713
class TestDateProperty:
26762714
@staticmethod

packages/google-cloud-ndb/tests/unit/test_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1185,7 +1185,7 @@ class Bar(model.Model):
11851185
@pytest.mark.usefixtures("in_context")
11861186
@unittest.mock.patch("google.cloud.ndb.query._datastore_query")
11871187
def test_constructor_with_class_attribute_projection_and_distinct(
1188-
_datastore_query
1188+
_datastore_query,
11891189
):
11901190
class Foo(model.Model):
11911191
string_attr = model.StringProperty()

0 commit comments

Comments
 (0)