>>> 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.
.. __: 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:
``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
---------------
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:
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
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()))
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):
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):
+ 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))
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")
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",
[