Skip to content

Commit 8e7ed99

Browse files
committed
Convert TIMESTAMP columns to timezone-aware datetime objects
This is additional API convenience based on the data type converter machinery. A `time_zone` keyword argument can be passed to both the `connect()` method, or when creating new `Cursor` objects. The `time_zone` attribute can also be changed at runtime on both the `connection` and `cursor` object instances. Examples: - connect('localhost:4200', time_zone=pytz.timezone("Australia/Sydney")) - connection.cursor(time_zone="+0530")
1 parent 012c257 commit 8e7ed99

File tree

9 files changed

+406
-5
lines changed

9 files changed

+406
-5
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Unreleased
88
- SQLAlchemy: Added support for ``crate_index`` and ``nullable`` attributes in
99
ORM column definitions.
1010

11+
- Added support for converting ``TIMESTAMP`` columns to timezone-aware
12+
``datetime`` objects, using the new ``time_zone`` keyword argument.
13+
14+
1115
2022/12/02 0.28.0
1216
=================
1317

docs/query.rst

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,67 @@ converter function defined as ``lambda``, which assigns ``yes`` for boolean
246246
['no']
247247

248248

249+
``TIMESTAMP`` conversion with time zone
250+
=======================================
251+
252+
Based on the data type converter functionality, the driver offers a convenient
253+
interface to make it return timezone-aware ``datetime`` objects, using the
254+
desired time zone.
255+
256+
For your reference, in the following examples, epoch 1658167836758 is
257+
``Mon, 18 Jul 2022 18:10:36 GMT``.
258+
259+
::
260+
261+
>>> import datetime
262+
>>> tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
263+
>>> cursor = connection.cursor(time_zone=tz_mst)
264+
265+
>>> cursor.execute("SELECT datetime_tz FROM locations ORDER BY name")
266+
267+
>>> cursor.fetchone()
268+
[datetime.datetime(2022, 7, 19, 1, 10, 36, 758000, tzinfo=datetime.timezone(datetime.timedelta(seconds=25200), 'MST'))]
269+
270+
For the ``time_zone`` keyword argument, different data types are supported.
271+
The available options are:
272+
273+
- ``datetime.timezone.utc``
274+
- ``datetime.timezone(datetime.timedelta(hours=7), name="MST")``
275+
- ``pytz.timezone("Australia/Sydney")``
276+
- ``zoneinfo.ZoneInfo("Australia/Sydney")``
277+
- ``+0530`` (UTC offset in string format)
278+
279+
Let's exercise all of them.
280+
281+
::
282+
283+
>>> cursor.time_zone = datetime.timezone.utc
284+
>>> cursor.execute("SELECT datetime_tz FROM locations ORDER BY name")
285+
>>> cursor.fetchone()
286+
[datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
287+
288+
>>> import pytz
289+
>>> cursor.time_zone = pytz.timezone("Australia/Sydney")
290+
>>> cursor.execute("SELECT datetime_tz FROM locations ORDER BY name")
291+
>>> cursor.fetchone()
292+
['foo', datetime.datetime(2022, 7, 19, 4, 10, 36, 758000, tzinfo=<DstTzInfo 'Australia/Sydney' AEST+10:00:00 STD>)]
293+
294+
>>> try:
295+
... import zoneinfo
296+
... except ImportError:
297+
... from backports import zoneinfo
298+
299+
>>> cursor.time_zone = zoneinfo.ZoneInfo("Australia/Sydney")
300+
>>> cursor.execute("SELECT datetime_tz FROM locations ORDER BY name")
301+
>>> cursor.fetchone()
302+
[datetime.datetime(2022, 7, 19, 4, 10, 36, 758000, tzinfo=zoneinfo.ZoneInfo(key='Australia/Sydney'))]
303+
304+
>>> cursor.time_zone = "+0530"
305+
>>> cursor.execute("SELECT datetime_tz FROM locations ORDER BY name")
306+
>>> cursor.fetchone()
307+
[datetime.datetime(2022, 7, 18, 23, 40, 36, 758000, tzinfo=datetime.timezone(datetime.timedelta(seconds=19800), '+0530'))]
308+
309+
249310
.. _Bulk inserts: https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#bulk-operations
250311
.. _CrateDB data type identifiers for the HTTP interface: https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types
251312
.. _Database API: http://www.python.org/dev/peps/pep-0249/

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ def read(path):
6161
install_requires=['urllib3>=1.9,<2'],
6262
extras_require=dict(
6363
sqlalchemy=['sqlalchemy>=1.0,<1.5',
64-
'geojson>=2.5.0,<3'],
64+
'geojson>=2.5.0,<3',
65+
'backports.zoneinfo<1; python_version<"3.9"'],
6566
test=['tox>=3,<4',
6667
'zope.testing>=4,<5',
6768
'zope.testrunner>=5,<6',

src/crate/client/connection.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def __init__(self,
4747
socket_tcp_keepintvl=None,
4848
socket_tcp_keepcnt=None,
4949
converter=None,
50+
time_zone=None,
5051
):
5152
"""
5253
:param servers:
@@ -103,9 +104,28 @@ def __init__(self,
103104
:param converter:
104105
(optional, defaults to ``None``)
105106
A `Converter` object to propagate to newly created `Cursor` objects.
107+
:param time_zone:
108+
(optional, defaults to ``None``)
109+
A time zone specifier used for returning `TIMESTAMP` types as
110+
timezone-aware native Python `datetime` objects.
111+
112+
Different data types are supported. Available options are:
113+
114+
- ``datetime.timezone.utc``
115+
- ``datetime.timezone(datetime.timedelta(hours=7), name="MST")``
116+
- ``pytz.timezone("Australia/Sydney")``
117+
- ``zoneinfo.ZoneInfo("Australia/Sydney")``
118+
- ``+0530`` (UTC offset in string format)
119+
120+
When `time_zone` is `None`, the returned `datetime` objects are
121+
"naive", without any `tzinfo`, converted using ``datetime.utcfromtimestamp(...)``.
122+
123+
When `time_zone` is given, the returned `datetime` objects are "aware",
124+
with `tzinfo` set, converted using ``datetime.fromtimestamp(..., tz=...)``.
106125
"""
107126

108127
self._converter = converter
128+
self.time_zone = time_zone
109129

110130
if client:
111131
self.client = client
@@ -135,10 +155,12 @@ def cursor(self, **kwargs) -> Cursor:
135155
Return a new Cursor Object using the connection.
136156
"""
137157
converter = kwargs.pop("converter", self._converter)
158+
time_zone = kwargs.pop("time_zone", self.time_zone)
138159
if not self._closed:
139160
return Cursor(
140161
connection=self,
141162
converter=converter,
163+
time_zone=time_zone,
142164
)
143165
else:
144166
raise ProgrammingError("Connection closed")

src/crate/client/converter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ def convert(value: Any) -> Optional[List[Any]]:
123123

124124
return convert
125125

126+
def set(self, type_: DataType, converter: ConverterFunction):
127+
self._mappings[type_] = converter
128+
126129

127130
class DefaultTypeConverter(Converter):
128131
def __init__(self, more_mappings: Optional[ConverterMapping] = None) -> None:

src/crate/client/cursor.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
# However, if you have executed another commercial license agreement
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
21+
from datetime import datetime, timedelta, timezone
2122

23+
from .converter import DataType
2224
import warnings
25+
import typing as t
2326

2427
from .converter import Converter
2528
from .exceptions import ProgrammingError
@@ -32,13 +35,15 @@ class Cursor(object):
3235
"""
3336
lastrowid = None # currently not supported
3437

35-
def __init__(self, connection, converter: Converter):
38+
def __init__(self, connection, converter: Converter, **kwargs):
3639
self.arraysize = 1
3740
self.connection = connection
3841
self._converter = converter
3942
self._closed = False
4043
self._result = None
4144
self.rows = None
45+
self._time_zone = None
46+
self.time_zone = kwargs.get("time_zone")
4247

4348
def execute(self, sql, parameters=None, bulk_parameters=None):
4449
"""
@@ -241,3 +246,72 @@ def _convert_rows(self):
241246
convert(value)
242247
for convert, value in zip(converters, row)
243248
]
249+
250+
@property
251+
def time_zone(self):
252+
"""
253+
Get the current time zone.
254+
"""
255+
return self._time_zone
256+
257+
@time_zone.setter
258+
def time_zone(self, tz):
259+
"""
260+
Set the time zone.
261+
262+
Different data types are supported. Available options are:
263+
264+
- ``datetime.timezone.utc``
265+
- ``datetime.timezone(datetime.timedelta(hours=7), name="MST")``
266+
- ``pytz.timezone("Australia/Sydney")``
267+
- ``zoneinfo.ZoneInfo("Australia/Sydney")``
268+
- ``+0530`` (UTC offset in string format)
269+
270+
When `time_zone` is `None`, the returned `datetime` objects are
271+
"naive", without any `tzinfo`, converted using ``datetime.utcfromtimestamp(...)``.
272+
273+
When `time_zone` is given, the returned `datetime` objects are "aware",
274+
with `tzinfo` set, converted using ``datetime.fromtimestamp(..., tz=...)``.
275+
"""
276+
277+
# Do nothing when time zone is reset.
278+
if tz is None:
279+
self._time_zone = None
280+
return
281+
282+
# Requesting datetime-aware `datetime` objects needs the data type converter.
283+
# Implicitly create one, when needed.
284+
if self._converter is None:
285+
self._converter = Converter()
286+
287+
# When the time zone is given as a string, assume UTC offset format, e.g. `+0530`.
288+
if isinstance(tz, str):
289+
tz = self._timezone_from_utc_offset(tz)
290+
291+
self._time_zone = tz
292+
293+
def _to_datetime_with_tz(value: t.Optional[float]) -> t.Optional[datetime]:
294+
"""
295+
Convert CrateDB's `TIMESTAMP` value to a native Python `datetime`
296+
object, with timezone-awareness.
297+
"""
298+
if value is None:
299+
return None
300+
return datetime.fromtimestamp(value / 1e3, tz=self._time_zone)
301+
302+
# Register converter function for `TIMESTAMP` type.
303+
self._converter.set(DataType.TIMESTAMP_WITH_TZ, _to_datetime_with_tz)
304+
self._converter.set(DataType.TIMESTAMP_WITHOUT_TZ, _to_datetime_with_tz)
305+
306+
@staticmethod
307+
def _timezone_from_utc_offset(tz) -> timezone:
308+
"""
309+
Convert UTC offset in string format (e.g. `+0530`) into `datetime.timezone` object.
310+
"""
311+
assert len(tz) == 5, f"Time zone '{tz}' is given in invalid UTC offset format"
312+
try:
313+
hours = int(tz[:3])
314+
minutes = int(tz[0] + tz[3:])
315+
return timezone(timedelta(hours=hours, minutes=minutes), name=tz)
316+
except Exception as ex:
317+
raise ValueError(f"Time zone '{tz}' is given in invalid UTC offset format: {ex}")

src/crate/client/doctests/cursor.txt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,76 @@ Proof that the converter works correctly, ``B\'0110\'`` should be converted to
364364
[6]
365365

366366

367+
``TIMESTAMP`` conversion with time zone
368+
=======================================
369+
370+
Based on the data type converter functionality, the driver offers a convenient
371+
interface to make it return timezone-aware ``datetime`` objects, using the
372+
desired time zone.
373+
374+
For your reference, in the following examples, epoch 1658167836758 is
375+
``Mon, 18 Jul 2022 18:10:36 GMT``.
376+
377+
::
378+
379+
>>> import datetime
380+
>>> tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
381+
>>> cursor = connection.cursor(time_zone=tz_mst)
382+
383+
>>> connection.client.set_next_response({
384+
... "col_types": [4, 11],
385+
... "rows":[ [ "foo", 1658167836758 ] ],
386+
... "cols":[ "name", "timestamp" ],
387+
... "rowcount":1,
388+
... "duration":123
389+
... })
390+
391+
>>> cursor.execute('')
392+
393+
>>> cursor.fetchone()
394+
['foo', datetime.datetime(2022, 7, 19, 1, 10, 36, 758000, tzinfo=datetime.timezone(datetime.timedelta(seconds=25200), 'MST'))]
395+
396+
For the ``time_zone`` keyword argument, different data types are supported.
397+
The available options are:
398+
399+
- ``datetime.timezone.utc``
400+
- ``datetime.timezone(datetime.timedelta(hours=7), name="MST")``
401+
- ``pytz.timezone("Australia/Sydney")``
402+
- ``zoneinfo.ZoneInfo("Australia/Sydney")``
403+
- ``+0530`` (UTC offset in string format)
404+
405+
Let's exercise all of them::
406+
407+
>>> cursor.time_zone = datetime.timezone.utc
408+
>>> cursor.execute('')
409+
>>> cursor.fetchone()
410+
['foo', datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
411+
412+
>>> import pytz
413+
>>> cursor.time_zone = pytz.timezone("Australia/Sydney")
414+
>>> cursor.execute('')
415+
>>> cursor.fetchone()
416+
['foo', datetime.datetime(2022, 7, 19, 4, 10, 36, 758000, tzinfo=<DstTzInfo 'Australia/Sydney' AEST+10:00:00 STD>)]
417+
418+
>>> try:
419+
... import zoneinfo
420+
... except ImportError:
421+
... from backports import zoneinfo
422+
>>> cursor.time_zone = zoneinfo.ZoneInfo("Australia/Sydney")
423+
>>> cursor.execute('')
424+
>>> record = cursor.fetchone()
425+
>>> record
426+
['foo', datetime.datetime(2022, 7, 19, 4, 10, 36, 758000, ...zoneinfo.ZoneInfo(key='Australia/Sydney'))]
427+
428+
>>> record[1].tzname()
429+
'AEST'
430+
431+
>>> cursor.time_zone = "+0530"
432+
>>> cursor.execute('')
433+
>>> cursor.fetchone()
434+
['foo', datetime.datetime(2022, 7, 18, 23, 40, 36, 758000, tzinfo=datetime.timezone(datetime.timedelta(seconds=19800), '+0530'))]
435+
436+
367437
.. Hidden: close connection
368438

369439
>>> connection.close()

src/crate/client/test_connection.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
13
from .http import Client
24
from crate.client import connect
35
from unittest import TestCase
@@ -23,7 +25,25 @@ def test_invalid_server_version(self):
2325
self.assertEqual((0, 0, 0), connection.lowest_server_version.version)
2426
connection.close()
2527

26-
def test_with_is_supported(self):
28+
def test_context_manager(self):
2729
with connect('localhost:4200') as conn:
2830
pass
2931
self.assertEqual(conn._closed, True)
32+
33+
def test_with_timezone(self):
34+
"""
35+
Verify the cursor objects will return timezone-aware `datetime` objects when requested to.
36+
When switching the time zone at runtime on the connection object, only new cursor objects
37+
will inherit the new time zone.
38+
"""
39+
40+
tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
41+
connection = connect('localhost:4200', time_zone=tz_mst)
42+
cursor = connection.cursor()
43+
self.assertEqual(cursor.time_zone.tzname(None), "MST")
44+
self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=25200))
45+
46+
connection.time_zone = datetime.timezone.utc
47+
cursor = connection.cursor()
48+
self.assertEqual(cursor.time_zone.tzname(None), "UTC")
49+
self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(0))

0 commit comments

Comments
 (0)