From 476a969bd83d94ea80ebce81a6fbb6abc3b9029f Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Mon, 14 Jun 2021 22:24:28 +0100 Subject: [PATCH] Handle correctly timestamps with fractions of minute in the timezone offset Close #1272. --- NEWS | 2 + doc/src/usage.rst | 28 ++++++++----- lib/tz.py | 45 +++++++++++++++------ psycopg/typecast_datetime.c | 75 ++++++++++++++++++++-------------- tests/test_dates.py | 81 +++++++++++++++++++++++++------------ 5 files changed, 152 insertions(+), 79 deletions(-) diff --git a/NEWS b/NEWS index 59679271..b3e14643 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,8 @@ What's new in psycopg 2.9 - ``with connection`` starts a transaction on autocommit transactions too (:ticket:`#941`). +- Timezones with fractional minutes are supported on Python 3.7 and following + (:ticket:`#1272`). - Escape table and column names in `~cursor.copy_from()` and `~cursor.copy_to()`. - Connection exceptions with sqlstate ``08XXX`` reclassified as diff --git a/doc/src/usage.rst b/doc/src/usage.rst index 3aafa903..335e750e 100644 --- a/doc/src/usage.rst +++ b/doc/src/usage.rst @@ -580,25 +580,33 @@ The PostgreSQL type :sql:`timestamp with time zone` (a.k.a. a `~datetime.datetime.tzinfo` attribute set to a `~psycopg2.tz.FixedOffsetTimezone` instance. - >>> cur.execute("SET TIME ZONE 'Europe/Rome';") # UTC + 1 hour - >>> cur.execute("SELECT '2010-01-01 10:30:45'::timestamptz;") + >>> cur.execute("SET TIME ZONE 'Europe/Rome'") # UTC + 1 hour + >>> cur.execute("SELECT '2010-01-01 10:30:45'::timestamptz") >>> cur.fetchone()[0].tzinfo psycopg2.tz.FixedOffsetTimezone(offset=60, name=None) -Note that only time zones with an integer number of minutes are supported: -this is a limitation of the Python `datetime` module. A few historical time -zones had seconds in the UTC offset: these time zones will have the offset -rounded to the nearest minute, with an error of up to 30 seconds. +.. note:: - >>> cur.execute("SET TIME ZONE 'Asia/Calcutta';") # offset was +5:53:20 - >>> cur.execute("SELECT '1930-01-01 10:30:45'::timestamptz;") - >>> cur.fetchone()[0].tzinfo - psycopg2.tz.FixedOffsetTimezone(offset=353, name=None) + Before Python 3.7, the `datetime` module only supported timezones with an + integer number of minutes. A few historical time zones had seconds in the + UTC offset: these time zones will have the offset rounded to the nearest + minute, with an error of up to 30 seconds, on Python versions before 3.7. + + >>> cur.execute("SET TIME ZONE 'Asia/Calcutta'") # offset was +5:21:10 + >>> cur.execute("SELECT '1900-01-01 10:30:45'::timestamptz") + >>> cur.fetchone()[0].tzinfo + # On Python 3.6: 5h, 21m + psycopg2.tz.FixedOffsetTimezone(offset=datetime.timedelta(0, 19260), name=None) + # On Python 3.7 and following: 5h, 21m, 10s + psycopg2.tz.FixedOffsetTimezone(offset=datetime.timedelta(seconds=19270), name=None) .. versionchanged:: 2.2.2 timezones with seconds are supported (with rounding). Previously such timezones raised an error. +.. versionchanged:: 2.9 + timezones with seconds are supported without rounding. + .. index:: double: Date objects; Infinite diff --git a/lib/tz.py b/lib/tz.py index 81cd8f8c..357aac0f 100644 --- a/lib/tz.py +++ b/lib/tz.py @@ -45,6 +45,11 @@ class FixedOffsetTimezone(datetime.tzinfo): offset and name that instance will be returned. This saves memory and improves comparability. + .. versionchanged:: 2.9 + + The constructor can take either a timedelta or a number of minutes of + offset. Previously only minutes were supported. + .. __: https://docs.python.org/library/datetime.html """ _name = None @@ -54,7 +59,9 @@ class FixedOffsetTimezone(datetime.tzinfo): def __init__(self, offset=None, name=None): if offset is not None: - self._offset = datetime.timedelta(minutes=offset) + if not isinstance(offset, datetime.timedelta): + offset = datetime.timedelta(minutes=offset) + self._offset = offset if name is not None: self._name = name @@ -70,13 +77,23 @@ class FixedOffsetTimezone(datetime.tzinfo): return tz def __repr__(self): - offset_mins = self._offset.seconds // 60 + self._offset.days * 24 * 60 return "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=%r)" \ - % (offset_mins, self._name) + % (self._offset, self._name) + + def __eq__(self, other): + if isinstance(other, FixedOffsetTimezone): + return self._offset == other._offset + else: + return NotImplemented + + def __ne__(self, other): + if isinstance(other, FixedOffsetTimezone): + return self._offset != other._offset + else: + return NotImplemented def __getinitargs__(self): - offset_mins = self._offset.seconds // 60 + self._offset.days * 24 * 60 - return offset_mins, self._name + return self._offset, self._name def utcoffset(self, dt): return self._offset @@ -84,14 +101,16 @@ class FixedOffsetTimezone(datetime.tzinfo): def tzname(self, dt): if self._name is not None: return self._name - else: - seconds = self._offset.seconds + self._offset.days * 86400 - hours, seconds = divmod(seconds, 3600) - minutes = seconds / 60 - if minutes: - return "%+03d:%d" % (hours, minutes) - else: - return "%+03d" % hours + + minutes, seconds = divmod(self._offset.total_seconds(), 60) + hours, minutes = divmod(minutes, 60) + rv = "%+03d" % hours + if minutes or seconds: + rv += ":%02d" % minutes + if seconds: + rv += ":%02d" % seconds + + return rv def dst(self, dt): return ZERO diff --git a/psycopg/typecast_datetime.c b/psycopg/typecast_datetime.c index 095fce17..d7000694 100644 --- a/psycopg/typecast_datetime.c +++ b/psycopg/typecast_datetime.c @@ -129,10 +129,11 @@ static PyObject * _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs) { PyObject* rv = NULL; + PyObject *tzoff = NULL; PyObject *tzinfo = NULL; PyObject *tzinfo_factory; int n, y=0, m=0, d=0; - int hh=0, mm=0, ss=0, us=0, tz=0; + int hh=0, mm=0, ss=0, us=0, tzsec=0; const char *tp = NULL; Dprintf("typecast_PYDATETIMETZ_cast: s = %s", str); @@ -147,11 +148,11 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs) } if (len > 0) { - n = typecast_parse_time(tp, NULL, &len, &hh, &mm, &ss, &us, &tz); + n = typecast_parse_time(tp, NULL, &len, &hh, &mm, &ss, &us, &tzsec); Dprintf("typecast_PYDATETIMETZ_cast: n = %d," " len = " FORMAT_CODE_PY_SSIZE_T "," - " hh = %d, mm = %d, ss = %d, us = %d, tz = %d", - n, len, hh, mm, ss, us, tz); + " hh = %d, mm = %d, ss = %d, us = %d, tzsec = %d", + n, len, hh, mm, ss, us, tzsec); if (n < 3 || n > 6) { PyErr_SetString(DataError, "unable to parse time"); goto exit; @@ -169,17 +170,20 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs) if (n >= 5 && tzinfo_factory != Py_None) { /* we have a time zone, calculate minutes and create appropriate tzinfo object calling the factory */ - Dprintf("typecast_PYDATETIMETZ_cast: UTC offset = %ds", tz); + Dprintf("typecast_PYDATETIMETZ_cast: UTC offset = %ds", tzsec); - /* The datetime module requires that time zone offsets be - a whole number of minutes, so truncate the seconds to the - closest minute. */ - // printf("%d %d %d\n", tz, tzmin, round(tz / 60.0)); - if (!(tzinfo = PyObject_CallFunction(tzinfo_factory, "i", - (int)round(tz / 60.0)))) { +#if PY_VERSION_HEX < 0x03070000 + /* Before Python 3.7 the timezone offset had to be a whole number + * of minutes, so round the seconds to the closest minute */ + tzsec = 60 * (int)round(tzsec / 60.0); +#endif + if (!(tzoff = PyDelta_FromDSU(0, tzsec, 0))) { goto exit; } + if (!(tzinfo = PyObject_CallFunctionObjArgs( + tzinfo_factory, tzoff, NULL))) { goto exit; } - } else { + } + else { Py_INCREF(Py_None); tzinfo = Py_None; } @@ -192,6 +196,7 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs) y, m, d, hh, mm, ss, us, tzinfo); exit: + Py_XDECREF(tzoff); Py_XDECREF(tzinfo); return rv; } @@ -232,17 +237,18 @@ typecast_PYDATETIMETZ_cast(const char *str, Py_ssize_t len, PyObject *curs) static PyObject * typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs) { - PyObject* obj = NULL; + PyObject* rv = NULL; + PyObject *tzoff = NULL; PyObject *tzinfo = NULL; PyObject *tzinfo_factory; - int n, hh=0, mm=0, ss=0, us=0, tz=0; + int n, hh=0, mm=0, ss=0, us=0, tzsec=0; if (str == NULL) { Py_RETURN_NONE; } - n = typecast_parse_time(str, NULL, &len, &hh, &mm, &ss, &us, &tz); + n = typecast_parse_time(str, NULL, &len, &hh, &mm, &ss, &us, &tzsec); Dprintf("typecast_PYTIME_cast: n = %d, len = " FORMAT_CODE_PY_SSIZE_T ", " - "hh = %d, mm = %d, ss = %d, us = %d, tz = %d", - n, len, hh, mm, ss, us, tz); + "hh = %d, mm = %d, ss = %d, us = %d, tzsec = %d", + n, len, hh, mm, ss, us, tzsec); if (n < 3 || n > 6) { PyErr_SetString(DataError, "unable to parse time"); @@ -254,25 +260,32 @@ typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs) } tzinfo_factory = ((cursorObject *)curs)->tzinfo_factory; if (n >= 5 && tzinfo_factory != Py_None) { - /* we have a time zone, calculate minutes and create + /* we have a time zone, calculate seconds and create appropriate tzinfo object calling the factory */ - Dprintf("typecast_PYTIME_cast: UTC offset = %ds", tz); + Dprintf("typecast_PYTIME_cast: UTC offset = %ds", tzsec); - /* The datetime module requires that time zone offsets be - a whole number of minutes, so truncate the seconds to the - closest minute. */ - tzinfo = PyObject_CallFunction(tzinfo_factory, "i", - (int)round(tz / 60.0)); - } else { +#if PY_VERSION_HEX < 0x03070000 + /* Before Python 3.7 the timezone offset had to be a whole number + * of minutes, so round the seconds to the closest minute */ + tzsec = 60 * (int)round(tzsec / 60.0); +#endif + if (!(tzoff = PyDelta_FromDSU(0, tzsec, 0))) { goto exit; } + if (!(tzinfo = PyObject_CallFunctionObjArgs(tzinfo_factory, tzoff, NULL))) { + goto exit; + } + } + else { Py_INCREF(Py_None); tzinfo = Py_None; } - if (tzinfo != NULL) { - obj = PyObject_CallFunction((PyObject*)PyDateTimeAPI->TimeType, "iiiiO", - hh, mm, ss, us, tzinfo); - Py_DECREF(tzinfo); - } - return obj; + + rv = PyObject_CallFunction((PyObject*)PyDateTimeAPI->TimeType, "iiiiO", + hh, mm, ss, us, tzinfo); + +exit: + Py_XDECREF(tzoff); + Py_XDECREF(tzinfo); + return rv; } diff --git a/tests/test_dates.py b/tests/test_dates.py index 29c37b09..4ba1455a 100755 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -23,6 +23,7 @@ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. +import sys import math import pickle from datetime import date, datetime, time, timedelta @@ -157,17 +158,27 @@ class DatetimeTests(ConnectingTestCase, CommonDatetimeTestsMixin): self.check_time_tz("-01", -3600) self.check_time_tz("+01:15", 4500) self.check_time_tz("-01:15", -4500) - # The Python datetime module does not support time zone - # offsets that are not a whole number of minutes. - # We round the offset to the nearest minute. - self.check_time_tz("+01:15:00", 60 * (60 + 15)) - self.check_time_tz("+01:15:29", 60 * (60 + 15)) - self.check_time_tz("+01:15:30", 60 * (60 + 16)) - self.check_time_tz("+01:15:59", 60 * (60 + 16)) - self.check_time_tz("-01:15:00", -60 * (60 + 15)) - self.check_time_tz("-01:15:29", -60 * (60 + 15)) - self.check_time_tz("-01:15:30", -60 * (60 + 16)) - self.check_time_tz("-01:15:59", -60 * (60 + 16)) + if sys.version_info < (3, 7): + # The Python < 3.7 datetime module does not support time zone + # offsets that are not a whole number of minutes. + # We round the offset to the nearest minute. + self.check_time_tz("+01:15:00", 60 * (60 + 15)) + self.check_time_tz("+01:15:29", 60 * (60 + 15)) + self.check_time_tz("+01:15:30", 60 * (60 + 16)) + self.check_time_tz("+01:15:59", 60 * (60 + 16)) + self.check_time_tz("-01:15:00", -60 * (60 + 15)) + self.check_time_tz("-01:15:29", -60 * (60 + 15)) + self.check_time_tz("-01:15:30", -60 * (60 + 16)) + self.check_time_tz("-01:15:59", -60 * (60 + 16)) + else: + self.check_time_tz("+01:15:00", 60 * (60 + 15)) + self.check_time_tz("+01:15:29", 60 * (60 + 15) + 29) + self.check_time_tz("+01:15:30", 60 * (60 + 15) + 30) + self.check_time_tz("+01:15:59", 60 * (60 + 15) + 59) + self.check_time_tz("-01:15:00", -(60 * (60 + 15))) + self.check_time_tz("-01:15:29", -(60 * (60 + 15) + 29)) + self.check_time_tz("-01:15:30", -(60 * (60 + 15) + 30)) + self.check_time_tz("-01:15:59", -(60 * (60 + 15) + 59)) def check_datetime_tz(self, str_offset, offset): base = datetime(2007, 1, 1, 13, 30, 29) @@ -192,17 +203,27 @@ class DatetimeTests(ConnectingTestCase, CommonDatetimeTestsMixin): self.check_datetime_tz("-01", -3600) self.check_datetime_tz("+01:15", 4500) self.check_datetime_tz("-01:15", -4500) - # The Python datetime module does not support time zone - # offsets that are not a whole number of minutes. - # We round the offset to the nearest minute. - self.check_datetime_tz("+01:15:00", 60 * (60 + 15)) - self.check_datetime_tz("+01:15:29", 60 * (60 + 15)) - self.check_datetime_tz("+01:15:30", 60 * (60 + 16)) - self.check_datetime_tz("+01:15:59", 60 * (60 + 16)) - self.check_datetime_tz("-01:15:00", -60 * (60 + 15)) - self.check_datetime_tz("-01:15:29", -60 * (60 + 15)) - self.check_datetime_tz("-01:15:30", -60 * (60 + 16)) - self.check_datetime_tz("-01:15:59", -60 * (60 + 16)) + if sys.version_info < (3, 7): + # The Python < 3.7 datetime module does not support time zone + # offsets that are not a whole number of minutes. + # We round the offset to the nearest minute. + self.check_datetime_tz("+01:15:00", 60 * (60 + 15)) + self.check_datetime_tz("+01:15:29", 60 * (60 + 15)) + self.check_datetime_tz("+01:15:30", 60 * (60 + 16)) + self.check_datetime_tz("+01:15:59", 60 * (60 + 16)) + self.check_datetime_tz("-01:15:00", -60 * (60 + 15)) + self.check_datetime_tz("-01:15:29", -60 * (60 + 15)) + self.check_datetime_tz("-01:15:30", -60 * (60 + 16)) + self.check_datetime_tz("-01:15:59", -60 * (60 + 16)) + else: + self.check_datetime_tz("+01:15:00", 60 * (60 + 15)) + self.check_datetime_tz("+01:15:29", 60 * (60 + 15) + 29) + self.check_datetime_tz("+01:15:30", 60 * (60 + 15) + 30) + self.check_datetime_tz("+01:15:59", 60 * (60 + 15) + 59) + self.check_datetime_tz("-01:15:00", -(60 * (60 + 15))) + self.check_datetime_tz("-01:15:29", -(60 * (60 + 15) + 29)) + self.check_datetime_tz("-01:15:30", -(60 * (60 + 15) + 30)) + self.check_datetime_tz("-01:15:59", -(60 * (60 + 15) + 59)) def test_parse_time_no_timezone(self): self.assertEqual(self.TIME("13:30:29", self.curs).tzinfo, None) @@ -628,17 +649,27 @@ class FixedOffsetTimezoneTests(unittest.TestCase): def test_repr_with_positive_offset(self): tzinfo = FixedOffsetTimezone(5 * 60) self.assertEqual(repr(tzinfo), - "psycopg2.tz.FixedOffsetTimezone(offset=300, name=None)") + "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)" + % timedelta(minutes=5 * 60)) def test_repr_with_negative_offset(self): tzinfo = FixedOffsetTimezone(-5 * 60) self.assertEqual(repr(tzinfo), - "psycopg2.tz.FixedOffsetTimezone(offset=-300, name=None)") + "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)" + % timedelta(minutes=-5 * 60)) + + def test_init_with_timedelta(self): + td = timedelta(minutes=5 * 60) + tzinfo = FixedOffsetTimezone(td) + self.assertEqual(tzinfo, FixedOffsetTimezone(5 * 60)) + self.assertEqual(repr(tzinfo), + "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)" % td) def test_repr_with_name(self): tzinfo = FixedOffsetTimezone(name="FOO") self.assertEqual(repr(tzinfo), - "psycopg2.tz.FixedOffsetTimezone(offset=0, name='FOO')") + "psycopg2.tz.FixedOffsetTimezone(offset=%r, name='FOO')" + % timedelta(0)) def test_instance_caching(self): self.assert_(FixedOffsetTimezone(name="FOO")