diff --git a/CLAUDE.md b/CLAUDE.md index 92a8fb9..e88635f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,12 @@ supported Python is 3.10 (`python_requires` in `setup.py`). - Query `params` use `str.format`, so literal `{`/`}` in SQL (e.g. cluster macros) must be escaped as `{{`/`}}`. - The TSV streaming backend asserts the response ends on a line boundary (`assert not buffer`). -- **Native engine specifics**: `decode=False` (raw bytes) is TSV-only; the Native format - carries no per-column timezone, so a tz-aware `DateTime`/`DateTime64` comes back as naive - UTC. A multi-block native INSERT is **not atomic** on a client-side encoding error (blocks +- **Timezones**: a `DateTime('TZ')` / `DateTime64(P, 'TZ')` column decodes to a tz-aware + `datetime` (`zoneinfo`); a column without a zone stays naive. Writers accept naive (column + wall-clock) and aware (any zone) values. One server quirk: the Native header ships a + `DateTime('TZ')` column as plain `DateTime` (DateTime64 keeps its zone), so that column is + naive UTC on the native engine only. +- **Native engine specifics**: `decode=False` (raw bytes) is TSV-only. A multi-block native INSERT is **not atomic** on a client-side encoding error (blocks already streamed are committed) — `insert_block_size=0` forces a single atomic block. The GC is disabled inside the synchronous decode/build sections of a fetch (mass allocation otherwise trips the cyclic collector); it is always restored via `try/finally`, and those diff --git a/README.md b/README.md index 490150c..a9d04ac 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,9 @@ Because binary encoding is type-specific, a binary INSERT first looks up the target column types (one lightweight query) and encodes the rows to match them. A couple of `native=True` caveats: `decode=False` (raw bytes) is a TSV-only -feature, and the Native format does not carry a column's timezone, so a tz-aware -`DateTime`/`DateTime64` comes back as a naive UTC `datetime` (TSV and RowBinary -apply the timezone). +feature, and ClickHouse's Native header drops the timezone of a `DateTime('TZ')` +column (`DateTime64` keeps it), so that one column comes back as a naive UTC +`datetime` on the native engine (TSV and RowBinary return it tz-aware). A native INSERT streams its body as several Native blocks so the server can insert one while the client encodes the next (`insert_block_size` rows per block, @@ -259,7 +259,9 @@ vice-versa. | `Enum16` | `str` | | `Date` | `datetime.date` | | `DateTime` | `datetime.datetime` | +| `DateTime('TZ')` | tz-aware `datetime` | | `DateTime64` | `datetime.datetime` | +| `DateTime64(P, 'TZ')`| tz-aware `datetime` | | `Decimal` | `decimal.Decimal` | | `Decimal32` | `decimal.Decimal` | | `Decimal64` | `decimal.Decimal` | @@ -273,6 +275,12 @@ vice-versa. | `Nullable(T)` | `None` or `T` | | `LowCardinality(T)` | `T` | | `Map(T1, T2)` | `Dict[T1, T2]` | + +A column declared with a timezone decodes to a `datetime` carrying that zone +(`zoneinfo.ZoneInfo`); a column without one stays naive. On INSERT a naive +`datetime` is taken as wall-clock time in the column's zone (UTC for a column +without one), and a tz-aware `datetime` of any zone is stored as the instant it +denotes. | `Nested(T1, T2, ...)` | `List[Tuple[T1, T2, ...], Tuple[T1, T2, ...]]` | diff --git a/aiochclient/_types.pyx b/aiochclient/_types.pyx index 9e840c3..f296a81 100644 --- a/aiochclient/_types.pyx +++ b/aiochclient/_types.pyx @@ -11,18 +11,12 @@ from uuid import UUID from zoneinfo import ZoneInfo from cpython cimport PyList_Append, PyUnicode_AsEncodedString, PyUnicode_Join +from cpython.datetime cimport date, date_new, datetime, datetime_new, import_datetime from cpython.list cimport PyList_GET_ITEM, PyList_GET_SIZE +from cpython.mem cimport PyMem_Free, PyMem_Malloc from cpython.ref cimport Py_INCREF from cpython.tuple cimport PyTuple_New, PyTuple_SET_ITEM -from cpython.datetime cimport ( - date, - date_new, - datetime, - datetime_new, - import_datetime, -) from cpython.unicode cimport PyUnicode_DecodeUTF8 -from cpython.mem cimport PyMem_Free, PyMem_Malloc from libc.stdint cimport ( int8_t, int16_t, @@ -216,6 +210,21 @@ cdef class Cursor: RB_EPOCH_DATE = _dt.date(1970, 1, 1) RB_EPOCH_DATETIME = _dt.datetime(1970, 1, 1) RB_EPOCH_UTC = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) +_MICROSECOND = _dt.timedelta(microseconds=1) + + +cpdef object to_epoch_micros(object value, object zone): + """Microseconds since the epoch for a naive or aware ``value``. + + Naive + column zone -> wall-clock in that zone. Naive + no zone -> UTC + wall-clock (the historical behaviour). Aware -> its real instant, whatever + the column zone is. + """ + if value.tzinfo is None: + if zone is None: + return (value - RB_EPOCH_DATETIME) // _MICROSECOND + value = value.replace(tzinfo=zone) + return (value - RB_EPOCH_UTC) // _MICROSECOND _TZ_UNSET = object() @@ -884,12 +893,14 @@ cdef class DateTimeType(_RBType): cdef object _convert(self, str string): string = string.strip("'") try: - return datetime_parse(string) + value = datetime_parse(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value cpdef object p_type(self, str string): return self._convert(string) @@ -902,14 +913,10 @@ cdef class DateTimeType(_RBType): zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + _dt.timedelta(seconds=seconds) - return _dt.datetime.fromtimestamp(seconds, zone).replace(tzinfo=None) + return _dt.datetime.fromtimestamp(seconds, zone) cpdef bytes write(self, value): - zone = self._zone() - if zone is None: - seconds = (value - RB_EPOCH_DATETIME) // _dt.timedelta(seconds=1) - else: - seconds = int(value.replace(tzinfo=zone).timestamp()) + seconds = to_epoch_micros(value, self._zone()) // 1_000_000 return int(seconds).to_bytes(4, "little") @@ -937,12 +944,14 @@ cdef class DateTime64Type(_RBType): cdef object _convert(self, str string): string = string.strip("'") try: - return datetime_parse_f(string) + value = datetime_parse_f(string) except ValueError: # In case of 0000-00-00 00:00:00.000 if string == "0000-00-00 00:00:00.000": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value cpdef object p_type(self, str string): return self._convert(string) @@ -959,18 +968,10 @@ cdef class DateTime64Type(_RBType): zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + _dt.timedelta(microseconds=micros) - return (RB_EPOCH_UTC + _dt.timedelta(microseconds=micros)).astimezone( - zone - ).replace(tzinfo=None) + return (RB_EPOCH_UTC + _dt.timedelta(microseconds=micros)).astimezone(zone) cpdef bytes write(self, value): - zone = self._zone() - if zone is None: - micros = (value - RB_EPOCH_DATETIME) // _dt.timedelta(microseconds=1) - else: - micros = (value.replace(tzinfo=zone) - RB_EPOCH_UTC) // _dt.timedelta( - microseconds=1 - ) + micros = to_epoch_micros(value, self._zone()) if self._precision <= 6: ticks = micros // 10 ** (6 - self._precision) else: diff --git a/aiochclient/native.py b/aiochclient/native.py index 5bbf25f..7e0c467 100644 --- a/aiochclient/native.py +++ b/aiochclient/native.py @@ -29,9 +29,9 @@ # Use the compiled Cursor (with bulk column reads) when available. try: - from aiochclient._types import Cursor # noqa: F401 + from aiochclient._types import Cursor, to_epoch_micros # noqa: F401 except ImportError: - from aiochclient.types import Cursor # noqa: F401 + from aiochclient.types import Cursor, to_epoch_micros # noqa: F401 # Compiled String-column encoder; pure-Python fallback below. try: @@ -213,10 +213,10 @@ def decode_column(cursor, n, ctype): return [data[i * width : (i + 1) * width].decode() for i in range(n)] if ctype == "Date": return cursor.read_date_column(n) - if ctype == "DateTime" or ctype.startswith("DateTime("): - # The Native format does not carry the per-column timezone (it always - # ships the UTC epoch), so DateTime is returned as a naive UTC datetime. - # The TSV/RowBinary engines apply the column timezone instead. + if ctype == "DateTime": + # ClickHouse's Native header drops the timezone of a DateTime('TZ') + # column (it ships plain "DateTime"; DateTime64 keeps its zone), so a + # timezone DateTime can only come back as naive UTC on this engine. return cursor.read_datetime_column(n) if ctype.startswith("Nullable("): inner = ctype[9:-1] @@ -341,7 +341,6 @@ async def rows_from_native( # Epochs for the null-slot defaults and the bulk Date/DateTime encoders. _EPOCH_DATE = dt.date(1970, 1, 1) _EPOCH_DATETIME = dt.datetime(1970, 1, 1) -_ONE_SECOND = dt.timedelta(seconds=1) def _write_varint(value): @@ -461,10 +460,10 @@ def encode_column(values, ctype): column.byteswap() return column.tobytes() if ctype == "DateTime": - # Naive UTC seconds (matches the no-timezone DateTime writer). Timezone - # DateTime / DateTime64 keep the per-value writer path below. + # Naive UTC / aware seconds (matches the no-timezone DateTime writer). + # Timezone DateTime / DateTime64 keep the per-value writer path below. column = array.array( - "I", [(v - _EPOCH_DATETIME) // _ONE_SECOND for v in values] + "I", [to_epoch_micros(v, None) // 1_000_000 for v in values] ) if not _LE: column.byteswap() diff --git a/aiochclient/types.py b/aiochclient/types.py index 1164138..2065fcd 100644 --- a/aiochclient/types.py +++ b/aiochclient/types.py @@ -144,6 +144,22 @@ def _parse_tz(name: str) -> Optional[str]: # Epoch used to turn day/second/tick offsets into python date/datetime objects. RB_EPOCH_DATE = dt.date(1970, 1, 1) RB_EPOCH_DATETIME = dt.datetime(1970, 1, 1) +RB_EPOCH_UTC = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) +_MICROSECOND = dt.timedelta(microseconds=1) + + +def to_epoch_micros(value: dt.datetime, zone) -> int: + """Microseconds since the epoch for a naive or aware ``value``. + + Naive + column zone -> wall-clock in that zone. Naive + no zone -> UTC + wall-clock (the historical behaviour). Aware -> its real instant, whatever + the column zone is. + """ + if value.tzinfo is None: + if zone is None: + return (value - RB_EPOCH_DATETIME) // _MICROSECOND + value = value.replace(tzinfo=zone) + return (value - RB_EPOCH_UTC) // _MICROSECOND def write_varint(value: int) -> bytes: @@ -455,12 +471,14 @@ def _zone(self): def p_type(self, string: str): string = string.strip("'") try: - return datetime_parse(string) + value = datetime_parse(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value def convert(self, value: bytes) -> Optional[dt.datetime]: return self.p_type(value.decode()) @@ -470,24 +488,18 @@ def read(self, cursor) -> dt.datetime: zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + dt.timedelta(seconds=seconds) - # Match the server's TSV output: wall-clock in the column timezone. - return dt.datetime.fromtimestamp(seconds, zone).replace(tzinfo=None) + return dt.datetime.fromtimestamp(seconds, zone) def write(self, value: dt.datetime) -> bytes: - zone = self._zone() - if zone is None: - seconds = (value - RB_EPOCH_DATETIME) // dt.timedelta(seconds=1) - else: - # ``value`` is naive wall-clock in the column timezone. - seconds = int(value.replace(tzinfo=zone).timestamp()) + seconds = to_epoch_micros(value, self._zone()) // 1_000_000 return seconds.to_bytes(4, "little") @staticmethod def unconvert(value: dt.datetime) -> bytes: - if value.microsecond != 0: - # In case of 0000-00-00 00:00:00.000 (datetime64) - return b"%a" % dt.datetime.strftime(value, '%Y-%m-%d %H:%M:%S.%f') - return b"%a" % dt.datetime.strftime(value, '%Y-%m-%d %H:%M:%S') + # str() keeps the sub-second part only when present (a DateTime column + # rejects it) and the UTC offset of an aware value, which ClickHouse + # applies. Same as the compiled ``unconvert_datetime``. + return b"'%s'" % str(value).encode() class DateTime64Type(BaseType): @@ -506,12 +518,14 @@ def _zone(self): def p_type(self, string: str): string = string.strip("'") try: - return datetime_parse_f(string) + value = datetime_parse_f(string) except ValueError: # In case of 0000-00-00 00:00:00 if string == "0000-00-00 00:00:00.000": return None raise + zone = self._zone() + return value.replace(tzinfo=zone) if zone else value def convert(self, value: bytes) -> Optional[dt.datetime]: return self.p_type(value.decode()) @@ -526,25 +540,10 @@ def read(self, cursor) -> dt.datetime: zone = self._zone() if zone is None: return RB_EPOCH_DATETIME + dt.timedelta(microseconds=micros) - # Match the server's TSV output: wall-clock in the column timezone. - return ( - ( - dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) - + dt.timedelta(microseconds=micros) - ) - .astimezone(zone) - .replace(tzinfo=None) - ) + return (RB_EPOCH_UTC + dt.timedelta(microseconds=micros)).astimezone(zone) def write(self, value: dt.datetime) -> bytes: - zone = self._zone() - if zone is None: - micros = (value - RB_EPOCH_DATETIME) // dt.timedelta(microseconds=1) - else: - aware = value.replace(tzinfo=zone) - micros = ( - aware - dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) - ) // dt.timedelta(microseconds=1) + micros = to_epoch_micros(value, self._zone()) if self._precision <= 6: ticks = micros // 10 ** (6 - self._precision) else: diff --git a/tests.py b/tests.py index 0002227..a612a51 100644 --- a/tests.py +++ b/tests.py @@ -6,6 +6,7 @@ from enum import Enum, IntEnum from ipaddress import IPv4Address, IPv6Address from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo import aiohttp import httpx @@ -21,6 +22,9 @@ def uuid(): return uuid4() +MOSCOW = ZoneInfo("Europe/Moscow") + + @pytest.fixture def rows(uuid): return [ @@ -73,7 +77,7 @@ def rows(uuid): [[1, 2, 3], [1, 2], [6, 7]], IPv4Address('116.253.40.133'), IPv6Address('2001:44c8:129:2632:33:0:252:2'), - dt.datetime(2018, 9, 21, 10, 32, 23, 999000), + dt.datetime(2018, 9, 21, 10, 32, 23, 999000, tzinfo=MOSCOW), True, {"hello": "world {' and other things"}, {"hello": {"inner": "world {' and other things"}}, @@ -174,7 +178,8 @@ async def all_types_db(chclient, rows): await chclient.execute("DROP TABLE IF EXISTS test_cache") await chclient.execute("DROP TABLE IF EXISTS test_cache_mv") await chclient.execute("DROP TABLE IF EXISTS test_insert_file") - await chclient.execute(""" + await chclient.execute( + """ CREATE TABLE all_types (uint8 UInt8, uint16 UInt16, uint32 UInt32, @@ -228,27 +233,34 @@ async def all_types_db(chclient, rows): nested_int Nested(value1 Integer, value2 Integer), nested_str_date Nested(value1 String, value2 Date) ) ENGINE = Memory - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE TABLE test_cache ( key String, int32Cache AggregateFunction(avg, Int32), float32Cache SimpleAggregateFunction(sum, Float64)) ENGINE = AggregatingMergeTree() ORDER BY key - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE MATERIALIZED VIEW test_cache_mv TO test_cache AS SELECT avgState(int32) AS int32Cache, sum(float32) AS float32Cache FROM all_types - """) - await chclient.execute(""" + """ + ) + await chclient.execute( + """ CREATE TABLE test_insert_file( uint32 UInt32, string String, date Date ) ENGINE = Memory - """) + """ + ) await chclient.execute("INSERT INTO all_types VALUES", *rows) @@ -257,7 +269,7 @@ def class_chclient(chclient, all_types_db, rows, request): request.cls.ch = chclient cls_rows = rows cls_rows[1][45] = dt.datetime( - 2019, 1, 1, 3, 0 + 2019, 1, 1, 3, 0, tzinfo=MOSCOW ) # DateTime64 always returns datetime type request.cls.rows = [tuple(r) for r in cls_rows] @@ -299,9 +311,7 @@ async def test_bad_select(self): @pytest.mark.types @pytest.mark.usefixtures("class_chclient", "class_engine") class TestTypes: - # The Native format ships DateTime64 without its timezone, so a tz-aware - # column comes back as naive UTC and cannot match the tz-aware TSV value. - NATIVE_UNSUPPORTED = {"datetime64"} + NATIVE_UNSUPPORTED = set() # The binary engines decode Float32 from its exact 4-byte IEEE-754 value, # whereas TSV ships ClickHouse's shorter text rounding (e.g. 23.432 vs @@ -928,7 +938,7 @@ async def test_ipv6(self): assert await self.select_field_bytes("ipv6") == b"2001:44c8:129:2632:33:0:252:2" async def test_datetime64(self): - result = dt.datetime(2018, 9, 21, 10, 32, 23, 999000) + result = dt.datetime(2018, 9, 21, 10, 32, 23, 999000, tzinfo=MOSCOW) assert await self.select_field("datetime64") == result record = await self.select_record("datetime64") assert record[0] == result @@ -1168,10 +1178,17 @@ async def test_explain_with_fetch(self): # https://github.com/maximdanilchenko/aiochclient/issues/98 rows = await self.ch.fetch("EXPLAIN SELECT 1") assert rows - assert all(isinstance(row[0], str) for row in rows) + # ClickHouse 26.7 made the `pretty` plan format the default, and it puts + # a blank line between the output columns and the plan tree. A blank TSV + # line decodes to an empty Record (that is what the `WITH TOTALS` + # separator looks like), so only the non-empty rows carry plan text. + assert any(len(row) for row in rows) + assert all(isinstance(row[0], str) for row in rows if len(row)) value = await self.ch.fetchval("EXPLAIN SYNTAX SELECT 1 + 1") - assert value == "SELECT 1 + 1" + # ClickHouse 26.7 prints operators as function calls; older servers + # printed the operator form. + assert value in ("SELECT plus(1, 1)", "SELECT 1 + 1") async def test_quoted_string(self): record = await self.ch.fetchrow("SELECT 'foo\\'bar' AS quoted_string") @@ -1579,7 +1596,8 @@ async def test_single_column_empty_string(self): async def test_insert_round_trip(self): binary = self._binary_client() await binary.execute("DROP TABLE IF EXISTS rb_insert") - await binary.execute(""" + await binary.execute( + """ CREATE TABLE rb_insert ( u8 UInt8, i64 Int64, f Float64, s String, fs FixedString(4), d Date, dttm DateTime('UTC'), @@ -1588,7 +1606,8 @@ async def test_insert_round_trip(self): e Enum8('a' = 1, 'b' = 2), arr Array(UInt8), m Map(String, UInt8), nn Nullable(UInt8), tup Tuple(UInt8, String) ) ENGINE = Memory - """) + """ + ) row = ( 7, -5, @@ -1596,8 +1615,8 @@ async def test_insert_round_trip(self): "hi", "abcd", dt.date(2021, 5, 6), - dt.datetime(2021, 5, 6, 7, 8, 9), - dt.datetime(2021, 5, 6, 10, 8, 9, 123000), + dt.datetime(2021, 5, 6, 7, 8, 9, tzinfo=ZoneInfo("UTC")), + dt.datetime(2021, 5, 6, 10, 8, 9, 123000, tzinfo=MOSCOW), Decimal("12.3456"), UUID("1ea47c97-16a8-4338-877e-66f464374944"), IPv4Address("1.2.3.4"), @@ -1630,6 +1649,96 @@ async def test_insert_with_column_list(self): await binary.execute("DROP TABLE IF EXISTS rb_insert_cols") +class TestDateTimeTimezone: + # https://github.com/maximdanilchenko/aiochclient/issues/136 + # A DateTime('TZ') / DateTime64(P, 'TZ') column decodes to a tz-aware + # datetime in the column zone; a timezone-less column stays naive. On + # INSERT every engine accepts naive (column wall-clock) and aware (any + # zone) values alike. + DDL = """ + CREATE TABLE dt_tz ( + d DateTime('Europe/Moscow'), d64 DateTime64(3, 'Europe/Moscow'), + n DateTime, n64 DateTime64(3), nd Nullable(DateTime64(3, 'UTC')), + arr Array(DateTime64(6, 'UTC')) + ) ENGINE = Memory + """ + + @pytest.fixture(params=["tsv", "binary", "native"]) + async def writer(self, chclient, request): + ch = ChClient( + chclient._http_client._session, + **({} if request.param == "tsv" else {request.param: True}), + ) + await ch.execute("DROP TABLE IF EXISTS dt_tz") + await ch.execute(self.DDL) + yield ch + await ch.execute("DROP TABLE IF EXISTS dt_tz") + + @pytest.fixture + def readers(self, chclient): + session = chclient._http_client._session + return { + "tsv": chclient, + "binary": ChClient(session, binary=True), + "native": ChClient(session, native=True), + } + + async def test_round_trip(self, writer, readers): + instant = dt.datetime(2021, 3, 28, 1, 30, 45, 123000, tzinfo=dt.timezone.utc) + naive = dt.datetime(2021, 3, 28, 1, 30, 45, 123000) + # DateTime (no 64) keeps whole seconds only. + instant_s, naive_s = instant.replace(microsecond=0), naive.replace( + microsecond=0 + ) + await writer.execute( + "INSERT INTO dt_tz VALUES", + (instant, instant, instant, instant, None, [instant, naive]), + (naive, naive, naive, naive, naive, []), + ) + # The server sees the very instants that were sent. + stamps = await writer.fetch( + "SELECT toUnixTimestamp(d), toUnixTimestamp64Milli(d64)," + " toUnixTimestamp(n), toUnixTimestamp64Milli(n64) FROM dt_tz ORDER BY d" + ) + # Naive 01:30 Moscow is 22:30 UTC the day before, so it sorts first. + assert [r[:] for r in stamps] == [ + ( + int(naive.replace(tzinfo=MOSCOW).timestamp()), + int(naive.replace(tzinfo=MOSCOW).timestamp() * 1000), + int(naive.replace(tzinfo=dt.timezone.utc).timestamp()), + int(naive.replace(tzinfo=dt.timezone.utc).timestamp() * 1000), + ), + ( + int(instant.timestamp()), + int(instant.timestamp() * 1000), + int(instant.timestamp()), + int(instant.timestamp() * 1000), + ), + ] + for engine, ch in readers.items(): + rows = await ch.fetch("SELECT * FROM dt_tz ORDER BY d") + wall, aware = rows[0], rows[1] + # aware == aware compares instants, whatever the zone. + assert aware["d64"] == instant, engine + assert aware["d64"].tzinfo.key == "Europe/Moscow", engine + assert aware["n"] == naive_s, engine + assert aware["n"].tzinfo is None, engine + assert aware["n64"] == naive and aware["n64"].tzinfo is None, engine + assert aware["nd"] is None, engine + assert aware["arr"] == [instant, naive.replace(tzinfo=dt.timezone.utc)] + assert aware["arr"][0].tzinfo.key == "UTC", engine + assert wall["d64"] == naive.replace(tzinfo=MOSCOW), engine + assert wall["nd"] == naive.replace(tzinfo=dt.timezone.utc), engine + if engine == "native": + # ClickHouse's Native header drops the zone of a DateTime('TZ') + # column, so it can only be decoded as naive UTC there. + assert aware["d"] == instant_s.replace(tzinfo=None), engine + else: + assert aware["d"] == instant_s, engine + assert aware["d"].tzinfo.key == "Europe/Moscow", engine + assert wall["d"] == naive_s.replace(tzinfo=MOSCOW), engine + + @pytest.mark.usefixtures("class_chclient") class TestNative: # https://github.com/maximdanilchenko/aiochclient/issues/134