]> git.ipfire.org Git - thirdparty/psycopg.git/commitdiff
fix: raise DataError dumping a time with ambiguous timezone
authorDaniele Varrazzo <daniele.varrazzo@gmail.com>
Tue, 3 Oct 2023 16:08:19 +0000 (18:08 +0200)
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>
Tue, 3 Oct 2023 16:32:31 +0000 (18:32 +0200)
Close #652

docs/basic/adapt.rst
docs/news.rst
psycopg/psycopg/types/datetime.py
psycopg_c/psycopg_c/types/datetime.pyx
tests/types/test_datetime.py

index 1538327a74290c6df7c05f47b594c356286d70f2..1730938dfe61effb784bedc73274aeb5ae810775 100644 (file)
@@ -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:
index 44f17f45f55ce366252d2713c16c4af596357178..39b8cb5a14765c51a488896a114e4fd5d8982fd8 100644 (file)
@@ -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
 ---------------
 
index 3fc4356d8a119ce786fe1ce9129a13a1e1278553..fa1da8effbe43717122f21f994e29fe445ba9b80 100644 (file)
@@ -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()))
 
 
index 1dd859816898838118e18f6fce87c7d5faf35876..0ec4179a20c9b7de9f68af2e65f3e2e91315f0df 100644 (file)
@@ -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, <PyObject *>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 * <int64_t>cdt.time_hour(obj))
         )
 
-        off = PyObject_CallFunctionObjArgs(time_utcoffset, <PyObject *>obj, NULL)
+        off = self._get_offset(obj)
         cdef int32_t offsec = int(PyObject_CallFunctionObjArgs(
             timedelta_total_seconds, <PyObject *>off, NULL))
 
index c89d364d9578c457ddbd57195b3f26be5e64803a..a64a62e13fc0a5c46cda154b69302fd2c12a643e 100644 (file)
@@ -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",
         [