From: Daniele Varrazzo Date: Tue, 3 Oct 2023 16:08:19 +0000 (+0200) Subject: fix: raise DataError dumping a time with ambiguous timezone X-Git-Tag: pool-3.1.9~11^2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=d0a9d5972ce3070236c9debeff07a1dcade9ac79;p=thirdparty%2Fpsycopg.git fix: raise DataError dumping a time with ambiguous timezone Close #652 --- diff --git a/docs/basic/adapt.rst b/docs/basic/adapt.rst index 1538327a7..1730938df 100644 --- a/docs/basic/adapt.rst +++ b/docs/basic/adapt.rst @@ -203,15 +203,18 @@ attribute:: >>> conn.execute("select '2048-07-08 12:00'::timestamptz").fetchone()[0] datetime.datetime(2048, 7, 8, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')) +.. __: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-TIMEZONE + .. note:: + PostgreSQL :sql:`timestamptz` doesn't store "a timestamp with a timezone attached": it stores a timestamp always in UTC, which is converted, on output, to the connection TimeZone setting:: - >>> conn.execute("SET TIMEZONE to 'Europe/Rome'") # UTC+2 in summer + >>> conn.execute("SET TIMEZONE to 'Europe/Rome'") # UTC+2 in summer - >>> conn.execute("SELECT '2042-07-01 12:00Z'::timestamptz").fetchone()[0] # UTC input - datetime.datetime(2042, 7, 1, 14, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Rome')) + >>> conn.execute("SELECT '2042-07-01 12:00Z'::timestamptz").fetchone()[0] # UTC input + datetime.datetime(2042, 7, 1, 14, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Rome')) Check out the `PostgreSQL documentation about timezones`__ for all the details. @@ -219,7 +222,32 @@ attribute:: .. __: https://www.postgresql.org/docs/current/datatype-datetime.html #DATATYPE-TIMEZONES -.. __: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-TIMEZONE +.. warning:: + + Times with timezone are silly objects, because you cannot know the offset + of a timezone with daylight saving time rules without knowing the date + too. + + Although silly, times with timezone are supported both by Python and by + PostgreSQL. However they are only supported with fixed offset timezones: + Postgres :sql:`timetz` values loaded from the database will result in + Python `!time` objects with `!tzinfo` attributes specified as fixed + offset, for instance by a `~datetime.timezone` value:: + + >>> conn.execute("SET TIMEZONE to 'Europe/Rome'") + + # UTC+1 in winter + >>> conn.execute("SELECT '2042-01-01 12:00Z'::timestamptz::timetz").fetchone()[0] + datetime.time(13, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=3600))) + + # UTC+2 in summer + >>> conn.execute("SELECT '2042-07-01 12:00Z'::timestamptz::timetz").fetchone()[0] + datetime.time(14, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200))) + + Dumping Python `!time` objects is only supported with fixed offset + `!tzinfo`, such as the ones returned by Postgres, or by whatever + `~datetime.tzinfo` implementation resulting in the time's + `~datetime.time.utcoffset` returning a value. .. _adapt-json: diff --git a/docs/news.rst b/docs/news.rst index 44f17f45f..39b8cb5a1 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -7,6 +7,17 @@ ``psycopg`` release notes ========================= +Future releases +--------------- + +Psycopg 3.1.13 (unreleased) +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Raise `DataError` instead of whatever internal failure trying to dump a + `~datetime.time` object with with a `!tzinfo` specified as + `~zoneinfo.ZoneInfo` (ambiguous offset, see :ticket:`#652`). + + Current release --------------- diff --git a/psycopg/psycopg/types/datetime.py b/psycopg/psycopg/types/datetime.py index 3fc4356d8..fa1da8eff 100644 --- a/psycopg/psycopg/types/datetime.py +++ b/psycopg/psycopg/types/datetime.py @@ -67,6 +67,14 @@ class _BaseTimeDumper(Dumper): def upgrade(self, obj: time, format: PyFormat) -> Dumper: raise NotImplementedError + def _get_offset(self, obj: time) -> timedelta: + offset = obj.utcoffset() + if offset is None: + raise DataError( + f"cannot calculate the offset of tzinfo '{obj.tzinfo}' without a date" + ) + return offset + class _BaseTimeTextDumper(_BaseTimeDumper): def dump(self, obj: time) -> bytes: @@ -86,6 +94,10 @@ class TimeDumper(_BaseTimeTextDumper): class TimeTzDumper(_BaseTimeTextDumper): oid = postgres.types["timetz"].oid + def dump(self, obj: time) -> bytes: + self._get_offset(obj) + return super().dump(obj) + class TimeBinaryDumper(_BaseTimeDumper): format = Format.BINARY @@ -112,8 +124,7 @@ class TimeTzBinaryDumper(_BaseTimeDumper): us = obj.microsecond + 1_000_000 * ( obj.second + 60 * (obj.minute + 60 * obj.hour) ) - off = obj.utcoffset() - assert off is not None + off = self._get_offset(obj) return _pack_timetz(us, -int(off.total_seconds())) diff --git a/psycopg_c/psycopg_c/types/datetime.pyx b/psycopg_c/psycopg_c/types/datetime.pyx index 1dd859816..0ec4179a2 100644 --- a/psycopg_c/psycopg_c/types/datetime.pyx +++ b/psycopg_c/psycopg_c/types/datetime.pyx @@ -113,6 +113,14 @@ cdef class _BaseTimeDumper(CDumper): cpdef upgrade(self, obj: time, format): raise NotImplementedError + cdef object _get_offset(self, obj): + off = PyObject_CallFunctionObjArgs(time_utcoffset, obj, NULL) + if off is None: + raise e.DataError( + f"cannot calculate the offset of tzinfo '{obj.tzinfo}' without a date" + ) + return off + cdef class _BaseTimeTextDumper(_BaseTimeDumper): @@ -147,6 +155,10 @@ cdef class TimeTzDumper(_BaseTimeTextDumper): oid = oids.TIMETZ_OID + cdef Py_ssize_t cdump(self, obj, bytearray rv, Py_ssize_t offset) except -1: + self._get_offset(obj) + return _BaseTimeTextDumper.cdump(self, obj, rv, offset) + @cython.final cdef class TimeBinaryDumper(_BaseTimeDumper): @@ -184,7 +196,7 @@ cdef class TimeTzBinaryDumper(_BaseTimeDumper): + 60 * (cdt.time_minute(obj) + 60 * cdt.time_hour(obj)) ) - off = PyObject_CallFunctionObjArgs(time_utcoffset, obj, NULL) + off = self._get_offset(obj) cdef int32_t offsec = int(PyObject_CallFunctionObjArgs( timedelta_total_seconds, off, NULL)) diff --git a/tests/types/test_datetime.py b/tests/types/test_datetime.py index c89d364d9..a64a62e13 100644 --- a/tests/types/test_datetime.py +++ b/tests/types/test_datetime.py @@ -4,6 +4,7 @@ import pytest from psycopg import DataError, pq, sql from psycopg.adapt import PyFormat +from psycopg._compat import ZoneInfo crdb_skip_datestyle = pytest.mark.crdb("skip", reason="set datestyle/intervalstyle") crdb_skip_negative_interval = pytest.mark.crdb("skip", reason="negative interval") @@ -569,6 +570,12 @@ class TestTimeTz: cur.execute(f"select '{expr}'::timetz = %{fmt_in.value}", (as_time(val),)) assert cur.fetchone()[0] is True + @pytest.mark.parametrize("fmt_in", PyFormat) + def test_dump_timetz_zoneinfo(self, conn, fmt_in): + t = dt.time(12, 0, tzinfo=ZoneInfo("Europe/Rome")) + with pytest.raises(DataError, match="Europe/Rome"): + conn.execute(f"select %{fmt_in.value}", (t,)) + @pytest.mark.parametrize( "val, expr, timezone", [