summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniele Varrazzo <daniele.varrazzo@gmail.com>2021-06-14 22:24:28 +0100
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>2021-06-14 22:25:02 +0100
commit476a969bd83d94ea80ebce81a6fbb6abc3b9029f (patch)
treee9929f57e7f51bb7269002e0b4111bde92b72893
parent566702688302c9d4575868e791092d64669104d8 (diff)
downloadpsycopg2-476a969bd83d94ea80ebce81a6fbb6abc3b9029f.tar.gz
Handle correctly timestamps with fractions of minute in the timezone offset
Close #1272.
-rw-r--r--NEWS2
-rw-r--r--doc/src/usage.rst28
-rw-r--r--lib/tz.py45
-rw-r--r--psycopg/typecast_datetime.c79
-rwxr-xr-xtests/test_dates.py81
5 files changed, 154 insertions, 81 deletions
diff --git a/NEWS b/NEWS
index 5967927..b3e1464 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 3aafa90..335e750 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 81cd8f8..357aac0 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 095fce1..d700069 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);
-
- /* 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)))) {
+ Dprintf("typecast_PYDATETIMETZ_cast: UTC offset = %ds", tzsec);
+
+#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);
-
- /* 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 {
+ Dprintf("typecast_PYTIME_cast: UTC offset = %ds", tzsec);
+
+#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 29c37b0..4ba1455 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")