Skip to content

Commit 7a8ea99

Browse files
authored
Add unicode datetime attribute (#33)
UnicodeDatetimeAttribute stores datetimes as 8601 ISO strings with offset. The storage representation of this format will look something like: {"key": {"S": "2020-11-22T03:22:33.444444-08:00"}} The attribute by default will add an offset to UTC if not present and make it timezone aware. It also as options for normalizing the date to UTC (for caching purposes) and adds support for custom formatting.
1 parent 1fd641d commit 7a8ea99

File tree

6 files changed

+293
-1
lines changed

6 files changed

+293
-1
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
44

55
- [pynamodb-attributes](#pynamodb-attributes)
6+
- [Testing](#testing)
67

78
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
89

@@ -17,3 +18,16 @@ This Python 3 library contains compound and high-level PynamoDB attributes:
1718
- `TimestampAttribute`, `TimestampMsAttribute`, `TimestampUsAttribute` – serializes `datetime`s as Unix epoch seconds, milliseconds (ms) or microseconds (µs)
1819
- `IntegerDateAttribute` - serializes `date` as an integer representing the Gregorian date (_e.g._ `20181231`)
1920
- `UUIDAttribute` - serializes a `UUID` Python object as a `S` type attribute (_e.g._ `'a8098c1a-f86e-11da-bd1a-00112444be1e'`)
21+
- `UnicodeDatetimeAttribute` - ISO8601 datetime strings with offset information
22+
23+
## Testing
24+
25+
The tests in this repository use an in-memory implementation of [`dynamodb`](https://aws.amazon.com/dynamodb). To run the tests locally, make sure [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) is running. It is available as a standalone binary, through package managers (e.g. [Homebrew](https://formulae.brew.sh/cask/dynamodb-local)) or as a Docker container:
26+
```shell
27+
docker run -d -p 8000:8000 amazon/dynamodb-local
28+
```
29+
30+
Afterwards, run tests as usual:
31+
```shell
32+
pytest tests
33+
```

pynamodb_attributes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .timestamp import TimestampAttribute
99
from .timestamp import TimestampMsAttribute
1010
from .timestamp import TimestampUsAttribute
11+
from .unicode_datetime import UnicodeDatetimeAttribute
1112
from .unicode_delimited_tuple import UnicodeDelimitedTupleAttribute
1213
from .unicode_enum import UnicodeEnumAttribute
1314
from .uuid import UUIDAttribute
@@ -26,4 +27,5 @@
2627
"TimestampMsAttribute",
2728
"TimestampUsAttribute",
2829
"UUIDAttribute",
30+
"UnicodeDatetimeAttribute",
2931
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from datetime import datetime
2+
from datetime import timezone
3+
from typing import Any
4+
from typing import Optional
5+
6+
import pynamodb.attributes
7+
from pynamodb.attributes import Attribute
8+
9+
10+
class UnicodeDatetimeAttribute(Attribute[datetime]):
11+
"""
12+
Stores a 'datetime.datetime' object as an ISO8601 formatted string
13+
14+
This is useful for wanting database readable datetime objects that also sort.
15+
16+
>>> class MyModel(Model):
17+
>>> created_at = UnicodeDatetimeAttribute()
18+
"""
19+
20+
attr_type = pynamodb.attributes.STRING
21+
22+
def __init__(
23+
self,
24+
*,
25+
force_tz: bool = True,
26+
force_utc: bool = False,
27+
fmt: Optional[str] = None,
28+
**kwargs: Any,
29+
) -> None:
30+
"""
31+
:param force_tz: If set it will add timezone info to the `datetime` value if no `tzinfo` is currently
32+
set before serializing, defaults to `True`
33+
:param force_utc: If set it will normalize the `datetime` to UTC before serializing the value
34+
:param fmt: If set this value will be used to format the `datetime` object for serialization
35+
and deserialization
36+
"""
37+
38+
super().__init__(**kwargs)
39+
self._force_tz = force_tz
40+
self._force_utc = force_utc
41+
self._fmt = fmt
42+
43+
def deserialize(self, value: str) -> datetime:
44+
return (
45+
datetime.fromisoformat(value)
46+
if self._fmt is None
47+
else datetime.strptime(value, self._fmt)
48+
)
49+
50+
def serialize(self, value: datetime) -> str:
51+
if self._force_tz and value.tzinfo is None:
52+
value = value.replace(tzinfo=timezone.utc)
53+
if self._force_utc:
54+
value = value.astimezone(tz=timezone.utc)
55+
return value.isoformat() if self._fmt is None else value.strftime(self._fmt)

setup.py

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

44
setup(
55
name="pynamodb-attributes",
6-
version="0.3.0",
6+
version="0.3.1",
77
description="Common attributes for PynamoDB",
88
url="https://www.github.com/lyft/pynamodb-attributes",
99
maintainer="Lyft",

tests/mypy_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,18 @@ class MyModel(Model):
141141
reveal_type(MyModel().my_attr) # E: Revealed type is "uuid.UUID*"
142142
""",
143143
)
144+
145+
146+
def test_unicode_datetime():
147+
assert_mypy_output(
148+
"""
149+
from pynamodb.models import Model
150+
from pynamodb_attributes import UnicodeDatetimeAttribute
151+
152+
class MyModel(Model):
153+
my_attr = UnicodeDatetimeAttribute()
154+
155+
reveal_type(MyModel.my_attr) # E: Revealed type is "pynamodb_attributes.unicode_datetime.UnicodeDatetimeAttribute"
156+
reveal_type(MyModel().my_attr) # E: Revealed type is "datetime.datetime*"
157+
""",
158+
)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
from datetime import datetime
2+
from unittest.mock import ANY
3+
4+
import pytest
5+
from pynamodb.attributes import UnicodeAttribute
6+
from pynamodb.models import Model
7+
8+
from pynamodb_attributes import UnicodeDatetimeAttribute
9+
from tests.connection import _connection
10+
from tests.meta import dynamodb_table_meta
11+
12+
13+
CUSTOM_FORMAT = "%m/%d/%Y, %H:%M:%S"
14+
CUSTOM_FORMAT_DATE = "11/22/2020, 11:22:33"
15+
TEST_ISO_DATE_NO_OFFSET = "2020-11-22T11:22:33.444444"
16+
TEST_ISO_DATE_UTC = "2020-11-22T11:22:33.444444+00:00"
17+
TEST_ISO_DATE_PST = "2020-11-22T03:22:33.444444-08:00"
18+
19+
20+
class MyModel(Model):
21+
Meta = dynamodb_table_meta(__name__)
22+
23+
key = UnicodeAttribute(hash_key=True)
24+
default = UnicodeDatetimeAttribute(null=True)
25+
no_force_tz = UnicodeDatetimeAttribute(force_tz=False, null=True)
26+
force_utc = UnicodeDatetimeAttribute(force_utc=True, null=True)
27+
force_utc_no_force_tz = UnicodeDatetimeAttribute(
28+
force_utc=True,
29+
force_tz=False,
30+
null=True,
31+
)
32+
custom_format = UnicodeDatetimeAttribute(fmt=CUSTOM_FORMAT, null=True)
33+
34+
35+
@pytest.fixture(scope="module", autouse=True)
36+
def create_table():
37+
MyModel.create_table()
38+
39+
40+
@pytest.mark.parametrize(
41+
["value", "expected_str", "expected_value"],
42+
[
43+
(
44+
datetime.fromisoformat(TEST_ISO_DATE_NO_OFFSET),
45+
TEST_ISO_DATE_UTC,
46+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
47+
),
48+
(
49+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
50+
TEST_ISO_DATE_UTC,
51+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
52+
),
53+
(
54+
datetime.fromisoformat(TEST_ISO_DATE_PST),
55+
TEST_ISO_DATE_PST,
56+
datetime.fromisoformat(TEST_ISO_DATE_PST),
57+
),
58+
],
59+
)
60+
def test_default_serialization(value, expected_str, expected_value, uuid_key):
61+
model = MyModel()
62+
model.key = uuid_key
63+
model.default = value
64+
65+
model.save()
66+
67+
actual = MyModel.get(hash_key=uuid_key)
68+
assert actual.default == expected_value
69+
70+
item = _connection(MyModel).get_item(uuid_key)
71+
assert item["Item"] == {"key": ANY, "default": {"S": expected_str}}
72+
73+
74+
@pytest.mark.parametrize(
75+
["value", "expected_str", "expected_value"],
76+
[
77+
(
78+
datetime.fromisoformat(TEST_ISO_DATE_NO_OFFSET),
79+
TEST_ISO_DATE_NO_OFFSET,
80+
datetime.fromisoformat(TEST_ISO_DATE_NO_OFFSET),
81+
),
82+
(
83+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
84+
TEST_ISO_DATE_UTC,
85+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
86+
),
87+
(
88+
datetime.fromisoformat(TEST_ISO_DATE_PST),
89+
TEST_ISO_DATE_PST,
90+
datetime.fromisoformat(TEST_ISO_DATE_PST),
91+
),
92+
],
93+
)
94+
def test_no_force_tz_serialization(value, expected_str, expected_value, uuid_key):
95+
model = MyModel()
96+
model.key = uuid_key
97+
model.no_force_tz = value
98+
99+
model.save()
100+
101+
actual = MyModel.get(hash_key=uuid_key)
102+
item = _connection(MyModel).get_item(uuid_key)
103+
104+
assert item["Item"] == {"key": ANY, "no_force_tz": {"S": expected_str}}
105+
106+
assert actual.no_force_tz == expected_value
107+
108+
109+
@pytest.mark.parametrize(
110+
["value", "expected_str", "expected_value"],
111+
[
112+
(
113+
datetime.fromisoformat(TEST_ISO_DATE_NO_OFFSET),
114+
TEST_ISO_DATE_UTC,
115+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
116+
),
117+
(
118+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
119+
TEST_ISO_DATE_UTC,
120+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
121+
),
122+
(
123+
datetime.fromisoformat(TEST_ISO_DATE_PST),
124+
TEST_ISO_DATE_UTC,
125+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
126+
),
127+
],
128+
)
129+
def test_force_utc_serialization(value, expected_str, expected_value, uuid_key):
130+
model = MyModel()
131+
model.key = uuid_key
132+
model.force_utc = value
133+
134+
model.save()
135+
136+
actual = MyModel.get(hash_key=uuid_key)
137+
item = _connection(MyModel).get_item(uuid_key)
138+
139+
assert item["Item"] == {"key": ANY, "force_utc": {"S": expected_str}}
140+
141+
assert actual.force_utc == expected_value
142+
143+
144+
@pytest.mark.parametrize(
145+
["value", "expected_str", "expected_value"],
146+
[
147+
(
148+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
149+
TEST_ISO_DATE_UTC,
150+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
151+
),
152+
(
153+
datetime.fromisoformat(TEST_ISO_DATE_PST),
154+
TEST_ISO_DATE_UTC,
155+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
156+
),
157+
],
158+
)
159+
def test_force_utc_no_force_tz_serialization(
160+
value,
161+
expected_str,
162+
expected_value,
163+
uuid_key,
164+
):
165+
model = MyModel()
166+
model.key = uuid_key
167+
model.force_utc_no_force_tz = value
168+
169+
model.save()
170+
171+
actual = MyModel.get(hash_key=uuid_key)
172+
item = _connection(MyModel).get_item(uuid_key)
173+
174+
assert item["Item"] == {"key": ANY, "force_utc_no_force_tz": {"S": expected_str}}
175+
176+
assert actual.force_utc_no_force_tz == expected_value
177+
178+
179+
@pytest.mark.parametrize(
180+
["value", "expected_str", "expected_value"],
181+
[
182+
(
183+
datetime.fromisoformat(TEST_ISO_DATE_UTC),
184+
CUSTOM_FORMAT_DATE,
185+
datetime(2020, 11, 22, 11, 22, 33),
186+
),
187+
],
188+
)
189+
def test_custom_format_force_tz_serialization(
190+
value,
191+
expected_str,
192+
expected_value,
193+
uuid_key,
194+
):
195+
model = MyModel()
196+
model.key = uuid_key
197+
model.custom_format = value
198+
199+
model.save()
200+
201+
actual = MyModel.get(hash_key=uuid_key)
202+
item = _connection(MyModel).get_item(uuid_key)
203+
204+
assert item["Item"] == {"key": ANY, "custom_format": {"S": expected_str}}
205+
206+
assert actual.custom_format == expected_value

0 commit comments

Comments
 (0)