Skip to content

Commit df4d991

Browse files
suristerbgunebakan
andauthored
Make datetime.time json serializable, to string ISO format. (#701)
* Make datetime.time json serializable, to string ISO format. * Improve tests based on new test structure * Add TIMETZ type conversion in cursor docs * Add _to_time() converter and related cursor test * Update HTTP request in LayerUtilsTest to include Accept header * Remove outside request from test_layer_from_uri() to fix flaky test --------- Co-authored-by: Bilal Tonga <bilaltonga@gmail.com>
1 parent dc3372f commit df4d991

7 files changed

Lines changed: 124 additions & 17 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Changes for crate
44

55
Unreleased
66
==========
7+
- Added JSON serialization support for Python's ``datetime.time`` type,
8+
encoding it as an ISO 8601 string compatible with CrateDB's ``TIMETZ``
9+
column type.
710

811
- Added gzip compression for outgoing request bodies via the ``compress``
912
parameter (default: ``8192`` bytes).

docs/by-example/cursor.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,8 @@ Python data type conversion
311311
===========================
312312

313313
The cursor object can optionally convert database types to native Python data
314-
types. Currently, this is implemented for the CrateDB data types ``IP`` and
315-
``TIMESTAMP`` on behalf of the ``DefaultTypeConverter``.
314+
types. Currently, this is implemented for the CrateDB data types ``IP``,
315+
``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``.
316316

317317
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
318318

@@ -329,6 +329,24 @@ types. Currently, this is implemented for the CrateDB data types ``IP`` and
329329
>>> cursor.fetchone()
330330
['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
331331

332+
CrateDB's ``TIMETZ`` type is returned over HTTP as ``[microseconds_since_midnight, tz_offset_seconds]``
333+
and decoded to a ``datetime.time`` object with the appropriate timezone:
334+
335+
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
336+
337+
>>> connection.client.set_next_response({
338+
... "col_types": [20],
339+
... "rows":[ [ [45045000000, 0] ] ],
340+
... "cols":[ "t" ],
341+
... "rowcount":1,
342+
... "duration":1
343+
... })
344+
345+
>>> cursor.execute('')
346+
347+
>>> cursor.fetchone()
348+
[datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)]
349+
332350

333351
Custom data type conversion
334352
===========================

src/crate/client/converter.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ def _to_datetime(value: Optional[float]) -> Optional[dt.datetime]:
5454
return dt.datetime.fromtimestamp(value / 1e3, tz=dt.timezone.utc)
5555

5656

57+
def _to_time(value: Optional[list]) -> Optional[dt.time]:
58+
"""
59+
Convert a CrateDB TIMETZ wire value to a Python ``datetime.time``.
60+
61+
CrateDB returns TIMETZ as
62+
``[microseconds_since_midnight, tz_offset_seconds]``
63+
via the HTTP interface.
64+
65+
https://docs.python.org/3/library/datetime.html#datetime.time
66+
"""
67+
if value is None:
68+
return None
69+
microseconds, tz_offset_seconds = value
70+
tz = dt.timezone(dt.timedelta(seconds=int(tz_offset_seconds)))
71+
t = (dt.datetime.min + dt.timedelta(microseconds=int(microseconds))).time()
72+
return t.replace(tzinfo=tz)
73+
74+
5775
def _to_default(value: Optional[Any]) -> Optional[Any]:
5876
return value
5977

@@ -98,6 +116,7 @@ class DataType(Enum):
98116
DataType.IP: _to_ipaddress,
99117
DataType.TIMESTAMP_WITH_TZ: _to_datetime,
100118
DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime,
119+
DataType.TIME: _to_time,
101120
}
102121

103122

src/crate/client/http.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ def json_encoder(obj: t.Any) -> t.Union[int, str]:
9999
- Python's `dt.datetime` and `dt.date` types will be
100100
serialized to `int` after converting to milliseconds
101101
since epoch.
102+
- Python's `dt.time` will be serialized to `str`, following
103+
the ISO format.
102104
103105
https://github.com/ijl/orjson#default
104106
https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#type-timestamp
@@ -114,6 +116,8 @@ def json_encoder(obj: t.Any) -> t.Union[int, str]:
114116
delta.microseconds / 1000.0
115117
+ (delta.seconds + delta.days * 24 * 3600) * 1000.0
116118
)
119+
if isinstance(obj, dt.time):
120+
return obj.isoformat()
117121
if isinstance(obj, dt.date):
118122
return calendar.timegm(obj.timetuple()) * 1000
119123
raise TypeError

tests/client/test_cursor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,41 @@ def test_execute_custom_converter(mocked_connection):
293293
]
294294

295295

296+
def test_execute_time_converter(mocked_connection):
297+
"""
298+
Verify that CrateDB's TIMETZ wire format
299+
``[microseconds, tz_offset_seconds]`` is decoded to a ``datetime.time``
300+
object by ``DefaultTypeConverter``.
301+
"""
302+
converter = DefaultTypeConverter()
303+
cursor = mocked_connection.cursor(converter=converter)
304+
response = {
305+
"col_types": [20],
306+
"cols": ["t"],
307+
"rows": [
308+
[[45045000000, 0]], # 12:30:45 UTC
309+
[[45045123456, 7200]], # 12:30:45.123456 +02:00
310+
[None],
311+
],
312+
"rowcount": 3,
313+
"duration": 1,
314+
}
315+
316+
with mock.patch.object(
317+
mocked_connection.client, "sql", return_value=response
318+
):
319+
cursor.execute("")
320+
result = cursor.fetchall()
321+
322+
assert result == [
323+
[datetime.time(12, 30, 45, 0,
324+
tzinfo=datetime.timezone.utc)],
325+
[datetime.time(12, 30, 45, 123456,
326+
tzinfo=datetime.timezone(datetime.timedelta(hours=2)))],
327+
[None],
328+
]
329+
330+
296331
def test_execute_with_converter_and_invalid_data_type(mocked_connection):
297332
converter = DefaultTypeConverter()
298333

tests/client/test_serialization.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,44 @@ def test_date_serialization():
125125
assert result == b"1461196800000"
126126

127127

128+
def test_naive_time_serialization():
129+
"""
130+
Verify that a naive `datetime.time` serializes to an ISO 8601 string.
131+
"""
132+
data = dt.time(12, 30, 45)
133+
result = json_dumps(data)
134+
assert result == b'"12:30:45"'
135+
136+
137+
def test_time_with_microseconds_serialization():
138+
"""
139+
Verify that `datetime.time` with microseconds serializes correctly.
140+
"""
141+
data = dt.time(12, 30, 45, 123456)
142+
result = json_dumps(data)
143+
assert result == b'"12:30:45.123456"'
144+
145+
146+
def test_aware_time_serialization():
147+
"""
148+
Verify that a timezone-aware `datetime.time` serializes to ISO 8601 format,
149+
including the UTC offset.
150+
"""
151+
data = dt.time(12, 30, 45, tzinfo=dt.timezone.utc)
152+
result = json_dumps(data)
153+
assert result == b'"12:30:45+00:00"'
154+
155+
156+
def test_aware_time_with_offset_serialization():
157+
"""
158+
Verify that a `datetime.time` with a non-UTC offset serializes correctly.
159+
"""
160+
tz = dt.timezone(dt.timedelta(hours=2))
161+
data = dt.time(12, 30, 45, tzinfo=tz)
162+
result = json_dumps(data)
163+
assert result == b'"12:30:45+02:00"'
164+
165+
128166
def test_uuid_serialization():
129167
"""
130168
Verify that a `uuid.UUID` can be serialized.

tests/testing/test_layer.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,11 @@
2222
import json
2323
import os
2424
import tempfile
25-
import urllib
2625
from io import BytesIO
2726
from pathlib import Path
2827
from unittest import TestCase, mock
2928

3029
import urllib3
31-
from verlib2 import Version
3230

3331
import crate
3432
from crate.testing.layer import (
@@ -38,7 +36,7 @@
3836
wait_for_http_url,
3937
)
4038
from tests.client.settings import crate_path
41-
from tests.conftest import download_cratedb
39+
from tests.conftest import download_cratedb, get_crate_url
4240

4341

4442
class LayerUtilsTest(TestCase):
@@ -86,18 +84,10 @@ def test_layer_from_uri(self):
8684
The CrateLayer can also be created by providing an URI that points to
8785
a CrateDB tarball.
8886
"""
89-
with urllib.request.urlopen(
90-
"https://crate.io/versions.json"
91-
) as response:
92-
versions = json.loads(response.read().decode())
93-
version = versions["crate_testing"]
94-
95-
self.assertGreaterEqual(Version(version), Version("4.5.0"))
96-
97-
uri = "https://cdn.crate.io/downloads/releases/crate-{}.tar.gz".format(
98-
version
99-
)
100-
layer = CrateLayer.from_uri(uri, name="crate-by-uri", http_port=42203)
87+
layer = CrateLayer.from_uri(get_crate_url(),
88+
name="crate-by-uri",
89+
http_port=42203
90+
)
10191
self.assertIsInstance(layer, CrateLayer)
10292

10393
@mock.patch.dict("os.environ", {}, clear=True)

0 commit comments

Comments
 (0)