Skip to content

Commit 7f1fca1

Browse files
committed
Support TzDate, TzDatetime and TzTimestamp types
Register the timezone-carrying primitive types so query results and parameters map to/from tz-aware datetime via zoneinfo (raw string when native result-set conversion is disabled). Reading them previously crashed with AttributeError.
1 parent 3f14e94 commit 7f1fca1

5 files changed

Lines changed: 143 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
* Support the timezone-carrying types `TzDate`, `TzDatetime` and `TzTimestamp` in query results and parameters (reading them previously raised `AttributeError`)
12
* Drop support for Python 3.8 and 3.9; the minimum supported version is now Python 3.10
23
* Accept native `datetime.datetime` values for `Datetime` and `Datetime64` query parameters (previously only integer seconds since the epoch were accepted)
34

docs/types.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ Date and time
183183
- extended-range timestamp (microsecond precision); maps to/from ``datetime.datetime``
184184
* - ``PrimitiveType.Interval64``
185185
- extended-range interval; maps to/from ``datetime.timedelta``
186+
* - ``PrimitiveType.TzDate``
187+
- date with a timezone name; maps to/from a tz-aware ``datetime.datetime`` at midnight
188+
* - ``PrimitiveType.TzDatetime``
189+
- date + time with a timezone name (second precision); maps to/from a tz-aware ``datetime.datetime``
190+
* - ``PrimitiveType.TzTimestamp``
191+
- date + time with a timezone name (microsecond precision); maps to/from a tz-aware ``datetime.datetime``
186192

187193
.. note::
188194

@@ -192,6 +198,16 @@ Date and time
192198
Integer inputs (seconds for ``Datetime``/``Datetime64``, microseconds for
193199
``Timestamp``/``Timestamp64`` since the Unix epoch) are still accepted.
194200

201+
.. note::
202+
203+
``TzDate``, ``TzDatetime`` and ``TzTimestamp`` carry an IANA timezone name.
204+
On write, pass a tz-aware ``datetime.datetime`` whose ``tzinfo`` is a
205+
``zoneinfo.ZoneInfo`` — its local wall-clock and zone name are stored. On
206+
read, they are returned as tz-aware ``datetime.datetime`` values (``TzDate``
207+
at midnight in that zone). When the matching
208+
``with_native_*_in_result_sets`` option is disabled, the raw
209+
``"<value>,<zone>"`` string is returned instead.
210+
195211
Other
196212
^^^^^
197213

tests/aio/test_types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import date, datetime, timedelta, timezone
55
from decimal import Decimal
66
from uuid import uuid4
7+
from zoneinfo import ZoneInfo
78

89

910
@pytest.mark.parametrize(
@@ -39,6 +40,9 @@
3940
(-100, "Interval"),
4041
(100, "Timestamp"),
4142
(1511789040123456, "Timestamp"),
43+
("2019-09-17,Europe/Moscow", "TzDate"),
44+
("2019-09-16T18:24:00,Europe/Moscow", "TzDatetime"),
45+
("2019-09-16T18:24:00.123456,Europe/Moscow", "TzTimestamp"),
4246
],
4347
)
4448
@pytest.mark.asyncio
@@ -54,6 +58,7 @@ async def test_types(driver, database, value, ydb_type):
5458
test_today = test_now.date()
5559
test_dt_today = datetime.today()
5660
tz4h = timezone(timedelta(hours=4))
61+
tzmsk = ZoneInfo("Europe/Moscow")
5762

5863

5964
@pytest.mark.parametrize(
@@ -76,6 +81,13 @@ async def test_types(driver, database, value, ydb_type):
7681
),
7782
('{"foo": "bar"}', "Json", {"foo": "bar"}),
7883
('{"foo": "bar"}', "JsonDocument", {"foo": "bar"}),
84+
(datetime(2019, 9, 17, tzinfo=tzmsk), "TzDate", datetime(2019, 9, 17, tzinfo=tzmsk)),
85+
(datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk), "TzDatetime", datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk)),
86+
(
87+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
88+
"TzTimestamp",
89+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
90+
),
7991
],
8092
)
8193
@pytest.mark.asyncio

tests/query/test_types.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import date, datetime, timedelta, timezone
55
from decimal import Decimal
66
from uuid import uuid4
7+
from zoneinfo import ZoneInfo
78

89

910
@pytest.mark.parametrize(
@@ -56,6 +57,9 @@
5657
(1511789040123456, ydb.PrimitiveType.Timestamp),
5758
(1511789040123456, ydb.PrimitiveType.Timestamp64),
5859
(-1511789040123456, ydb.PrimitiveType.Timestamp64),
60+
("2019-09-17,Europe/Moscow", ydb.PrimitiveType.TzDate),
61+
("2019-09-16T18:24:00,Europe/Moscow", ydb.PrimitiveType.TzDatetime),
62+
("2019-09-16T18:24:00.123456,Europe/Moscow", ydb.PrimitiveType.TzTimestamp),
5963
],
6064
)
6165
def test_types(driver_sync: ydb.Driver, value, ydb_type):
@@ -81,6 +85,7 @@ def test_types(driver_sync: ydb.Driver, value, ydb_type):
8185
test_today = test_now.date()
8286
test_dt_today = datetime.today()
8387
tz4h = timezone(timedelta(hours=4))
88+
tzmsk = ZoneInfo("Europe/Moscow")
8489

8590

8691
@pytest.mark.parametrize(
@@ -108,6 +113,17 @@ def test_types(driver_sync: ydb.Driver, value, ydb_type):
108113
),
109114
('{"foo": "bar"}', ydb.PrimitiveType.Json, {"foo": "bar"}),
110115
('{"foo": "bar"}', ydb.PrimitiveType.JsonDocument, {"foo": "bar"}),
116+
(datetime(2019, 9, 17, tzinfo=tzmsk), ydb.PrimitiveType.TzDate, datetime(2019, 9, 17, tzinfo=tzmsk)),
117+
(
118+
datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk),
119+
ydb.PrimitiveType.TzDatetime,
120+
datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk),
121+
),
122+
(
123+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
124+
ydb.PrimitiveType.TzTimestamp,
125+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
126+
),
111127
],
112128
)
113129
def test_types_native(driver_sync, value, ydb_type, result_value):
@@ -138,6 +154,24 @@ def test_types_native(driver_sync, value, ydb_type, result_value):
138154
(test_td, ydb.PrimitiveType.Interval, "-PT0.0001S", test_td),
139155
(test_td, ydb.PrimitiveType.Interval64, "-PT0.0001S", test_td),
140156
(test_old_date, ydb.PrimitiveType.Timestamp64, "1221-01-01T00:00:00Z", test_old_date),
157+
(
158+
datetime(2019, 9, 17, tzinfo=tzmsk),
159+
ydb.PrimitiveType.TzDate,
160+
"2019-09-17,Europe/Moscow",
161+
datetime(2019, 9, 17, tzinfo=tzmsk),
162+
),
163+
(
164+
datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk),
165+
ydb.PrimitiveType.TzDatetime,
166+
"2019-09-16T18:24:00,Europe/Moscow",
167+
datetime(2019, 9, 16, 18, 24, tzinfo=tzmsk),
168+
),
169+
(
170+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
171+
ydb.PrimitiveType.TzTimestamp,
172+
"2019-09-16T18:24:00.123456,Europe/Moscow",
173+
datetime(2019, 9, 16, 18, 24, 0, 123456, tzinfo=tzmsk),
174+
),
141175
],
142176
)
143177
def test_type_str_repr(driver_sync, value, ydb_type, str_repr, result_value):

ydb/types.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
from . import _utilities, _apis
99
from datetime import date, datetime, timedelta, timezone
10+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
1011
import typing
1112
import uuid
1213
import struct
@@ -80,6 +81,67 @@ def _to_datetime64(pb: ydb_value_pb2.Value, value: typing.Union[datetime, int])
8081
pb.int64_value = value
8182

8283

84+
def _tz_name(value: datetime) -> str:
85+
tz = value.tzinfo
86+
if isinstance(tz, ZoneInfo):
87+
return tz.key
88+
return str(tz)
89+
90+
91+
def _parse_tz(value: str) -> typing.Union[datetime, str]:
92+
wall_clock, _, tz_name = value.partition(",")
93+
try:
94+
tz = ZoneInfo(tz_name)
95+
except ZoneInfoNotFoundError:
96+
# No IANA database available for this zone; return the raw text as-is.
97+
return value
98+
if "T" in wall_clock:
99+
naive = datetime.fromisoformat(wall_clock)
100+
else:
101+
parsed_date = date.fromisoformat(wall_clock)
102+
naive = datetime(parsed_date.year, parsed_date.month, parsed_date.day)
103+
return naive.replace(tzinfo=tz)
104+
105+
106+
def _from_tz_date(x: str, table_client_settings: table.TableClientSettings) -> typing.Union[datetime, str]:
107+
if table_client_settings is not None and table_client_settings._native_date_in_result_sets:
108+
return _parse_tz(x)
109+
return x
110+
111+
112+
def _to_tz_date(pb: ydb_value_pb2.Value, value: typing.Union[datetime, str]) -> None:
113+
if isinstance(value, datetime):
114+
pb.text_value = value.strftime("%Y-%m-%d") + "," + _tz_name(value)
115+
else:
116+
pb.text_value = value
117+
118+
119+
def _from_tz_datetime(x: str, table_client_settings: table.TableClientSettings) -> typing.Union[datetime, str]:
120+
if table_client_settings is not None and table_client_settings._native_datetime_in_result_sets:
121+
return _parse_tz(x)
122+
return x
123+
124+
125+
def _to_tz_datetime(pb: ydb_value_pb2.Value, value: typing.Union[datetime, str]) -> None:
126+
if isinstance(value, datetime):
127+
pb.text_value = value.strftime("%Y-%m-%dT%H:%M:%S") + "," + _tz_name(value)
128+
else:
129+
pb.text_value = value
130+
131+
132+
def _from_tz_timestamp(x: str, table_client_settings: table.TableClientSettings) -> typing.Union[datetime, str]:
133+
if table_client_settings is not None and table_client_settings._native_timestamp_in_result_sets:
134+
return _parse_tz(x)
135+
return x
136+
137+
138+
def _to_tz_timestamp(pb: ydb_value_pb2.Value, value: typing.Union[datetime, str]) -> None:
139+
if isinstance(value, datetime):
140+
pb.text_value = value.strftime("%Y-%m-%dT%H:%M:%S.%f") + "," + _tz_name(value)
141+
else:
142+
pb.text_value = value
143+
144+
83145
def _from_json(x: typing.Union[str, bytearray, bytes], table_client_settings: table.TableClientSettings) -> typing.Any:
84146
if table_client_settings is not None and table_client_settings._native_json_in_result_sets:
85147
return json.loads(x)
@@ -214,6 +276,24 @@ class PrimitiveType(enum.Enum):
214276
_from_timestamp64,
215277
_to_timestamp64,
216278
)
279+
TzDate = (
280+
_apis.primitive_types.TZ_DATE,
281+
"text_value",
282+
_from_tz_date,
283+
_to_tz_date,
284+
)
285+
TzDatetime = (
286+
_apis.primitive_types.TZ_DATETIME,
287+
"text_value",
288+
_from_tz_datetime,
289+
_to_tz_datetime,
290+
)
291+
TzTimestamp = (
292+
_apis.primitive_types.TZ_TIMESTAMP,
293+
"text_value",
294+
_from_tz_timestamp,
295+
_to_tz_timestamp,
296+
)
217297
Interval = (
218298
_apis.primitive_types.INTERVAL,
219299
None,

0 commit comments

Comments
 (0)