Skip to content

Commit 476a969

Browse files
committed
Handle correctly timestamps with fractions of minute in the timezone offset
Close psycopg#1272.
1 parent 5667026 commit 476a969

File tree

5 files changed

+154
-81
lines changed

5 files changed

+154
-81
lines changed

NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ What's new in psycopg 2.9
66

77
- ``with connection`` starts a transaction on autocommit transactions too
88
(:ticket:`#941`).
9+
- Timezones with fractional minutes are supported on Python 3.7 and following
10+
(:ticket:`#1272`).
911
- Escape table and column names in `~cursor.copy_from()` and
1012
`~cursor.copy_to()`.
1113
- Connection exceptions with sqlstate ``08XXX`` reclassified as

doc/src/usage.rst

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -580,25 +580,33 @@ The PostgreSQL type :sql:`timestamp with time zone` (a.k.a.
580580
a `~datetime.datetime.tzinfo` attribute set to a
581581
`~psycopg2.tz.FixedOffsetTimezone` instance.
582582

583-
>>> cur.execute("SET TIME ZONE 'Europe/Rome';") # UTC + 1 hour
584-
>>> cur.execute("SELECT '2010-01-01 10:30:45'::timestamptz;")
583+
>>> cur.execute("SET TIME ZONE 'Europe/Rome'") # UTC + 1 hour
584+
>>> cur.execute("SELECT '2010-01-01 10:30:45'::timestamptz")
585585
>>> cur.fetchone()[0].tzinfo
586586
psycopg2.tz.FixedOffsetTimezone(offset=60, name=None)
587587

588-
Note that only time zones with an integer number of minutes are supported:
589-
this is a limitation of the Python `datetime` module. A few historical time
590-
zones had seconds in the UTC offset: these time zones will have the offset
591-
rounded to the nearest minute, with an error of up to 30 seconds.
588+
.. note::
592589

593-
>>> cur.execute("SET TIME ZONE 'Asia/Calcutta';") # offset was +5:53:20
594-
>>> cur.execute("SELECT '1930-01-01 10:30:45'::timestamptz;")
595-
>>> cur.fetchone()[0].tzinfo
596-
psycopg2.tz.FixedOffsetTimezone(offset=353, name=None)
590+
Before Python 3.7, the `datetime` module only supported timezones with an
591+
integer number of minutes. A few historical time zones had seconds in the
592+
UTC offset: these time zones will have the offset rounded to the nearest
593+
minute, with an error of up to 30 seconds, on Python versions before 3.7.
594+
595+
>>> cur.execute("SET TIME ZONE 'Asia/Calcutta'") # offset was +5:21:10
596+
>>> cur.execute("SELECT '1900-01-01 10:30:45'::timestamptz")
597+
>>> cur.fetchone()[0].tzinfo
598+
# On Python 3.6: 5h, 21m
599+
psycopg2.tz.FixedOffsetTimezone(offset=datetime.timedelta(0, 19260), name=None)
600+
# On Python 3.7 and following: 5h, 21m, 10s
601+
psycopg2.tz.FixedOffsetTimezone(offset=datetime.timedelta(seconds=19270), name=None)
597602

598603
.. versionchanged:: 2.2.2
599604
timezones with seconds are supported (with rounding). Previously such
600605
timezones raised an error.
601606

607+
.. versionchanged:: 2.9
608+
timezones with seconds are supported without rounding.
609+
602610

603611
.. index::
604612
double: Date objects; Infinite

lib/tz.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ class FixedOffsetTimezone(datetime.tzinfo):
4545
offset and name that instance will be returned. This saves memory and
4646
improves comparability.
4747
48+
.. versionchanged:: 2.9
49+
50+
The constructor can take either a timedelta or a number of minutes of
51+
offset. Previously only minutes were supported.
52+
4853
.. __: https://docs.python.org/library/datetime.html
4954
"""
5055
_name = None
@@ -54,7 +59,9 @@ class FixedOffsetTimezone(datetime.tzinfo):
5459

5560
def __init__(self, offset=None, name=None):
5661
if offset is not None:
57-
self._offset = datetime.timedelta(minutes=offset)
62+
if not isinstance(offset, datetime.timedelta):
63+
offset = datetime.timedelta(minutes=offset)
64+
self._offset = offset
5865
if name is not None:
5966
self._name = name
6067

@@ -70,28 +77,40 @@ def __new__(cls, offset=None, name=None):
7077
return tz
7178

7279
def __repr__(self):
73-
offset_mins = self._offset.seconds // 60 + self._offset.days * 24 * 60
7480
return "psycopg2.tz.FixedOffsetTimezone(offset=%r, name=%r)" \
75-
% (offset_mins, self._name)
81+
% (self._offset, self._name)
82+
83+
def __eq__(self, other):
84+
if isinstance(other, FixedOffsetTimezone):
85+
return self._offset == other._offset
86+
else:
87+
return NotImplemented
88+
89+
def __ne__(self, other):
90+
if isinstance(other, FixedOffsetTimezone):
91+
return self._offset != other._offset
92+
else:
93+
return NotImplemented
7694

7795
def __getinitargs__(self):
78-
offset_mins = self._offset.seconds // 60 + self._offset.days * 24 * 60
79-
return offset_mins, self._name
96+
return self._offset, self._name
8097

8198
def utcoffset(self, dt):
8299
return self._offset
83100

84101
def tzname(self, dt):
85102
if self._name is not None:
86103
return self._name
87-
else:
88-
seconds = self._offset.seconds + self._offset.days * 86400
89-
hours, seconds = divmod(seconds, 3600)
90-
minutes = seconds / 60
91-
if minutes:
92-
return "%+03d:%d" % (hours, minutes)
93-
else:
94-
return "%+03d" % hours
104+
105+
minutes, seconds = divmod(self._offset.total_seconds(), 60)
106+
hours, minutes = divmod(minutes, 60)
107+
rv = "%+03d" % hours
108+
if minutes or seconds:
109+
rv += ":%02d" % minutes
110+
if seconds:
111+
rv += ":%02d" % seconds
112+
113+
return rv
95114

96115
def dst(self, dt):
97116
return ZERO

psycopg/typecast_datetime.c

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,11 @@ static PyObject *
129129
_parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs)
130130
{
131131
PyObject* rv = NULL;
132+
PyObject *tzoff = NULL;
132133
PyObject *tzinfo = NULL;
133134
PyObject *tzinfo_factory;
134135
int n, y=0, m=0, d=0;
135-
int hh=0, mm=0, ss=0, us=0, tz=0;
136+
int hh=0, mm=0, ss=0, us=0, tzsec=0;
136137
const char *tp = NULL;
137138

138139
Dprintf("typecast_PYDATETIMETZ_cast: s = %s", str);
@@ -147,11 +148,11 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs)
147148
}
148149

149150
if (len > 0) {
150-
n = typecast_parse_time(tp, NULL, &len, &hh, &mm, &ss, &us, &tz);
151+
n = typecast_parse_time(tp, NULL, &len, &hh, &mm, &ss, &us, &tzsec);
151152
Dprintf("typecast_PYDATETIMETZ_cast: n = %d,"
152153
" len = " FORMAT_CODE_PY_SSIZE_T ","
153-
" hh = %d, mm = %d, ss = %d, us = %d, tz = %d",
154-
n, len, hh, mm, ss, us, tz);
154+
" hh = %d, mm = %d, ss = %d, us = %d, tzsec = %d",
155+
n, len, hh, mm, ss, us, tzsec);
155156
if (n < 3 || n > 6) {
156157
PyErr_SetString(DataError, "unable to parse time");
157158
goto exit;
@@ -169,17 +170,20 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs)
169170
if (n >= 5 && tzinfo_factory != Py_None) {
170171
/* we have a time zone, calculate minutes and create
171172
appropriate tzinfo object calling the factory */
172-
Dprintf("typecast_PYDATETIMETZ_cast: UTC offset = %ds", tz);
173-
174-
/* The datetime module requires that time zone offsets be
175-
a whole number of minutes, so truncate the seconds to the
176-
closest minute. */
177-
// printf("%d %d %d\n", tz, tzmin, round(tz / 60.0));
178-
if (!(tzinfo = PyObject_CallFunction(tzinfo_factory, "i",
179-
(int)round(tz / 60.0)))) {
173+
Dprintf("typecast_PYDATETIMETZ_cast: UTC offset = %ds", tzsec);
174+
175+
#if PY_VERSION_HEX < 0x03070000
176+
/* Before Python 3.7 the timezone offset had to be a whole number
177+
* of minutes, so round the seconds to the closest minute */
178+
tzsec = 60 * (int)round(tzsec / 60.0);
179+
#endif
180+
if (!(tzoff = PyDelta_FromDSU(0, tzsec, 0))) { goto exit; }
181+
if (!(tzinfo = PyObject_CallFunctionObjArgs(
182+
tzinfo_factory, tzoff, NULL))) {
180183
goto exit;
181184
}
182-
} else {
185+
}
186+
else {
183187
Py_INCREF(Py_None);
184188
tzinfo = Py_None;
185189
}
@@ -192,6 +196,7 @@ _parse_noninftz(const char *str, Py_ssize_t len, PyObject *curs)
192196
y, m, d, hh, mm, ss, us, tzinfo);
193197

194198
exit:
199+
Py_XDECREF(tzoff);
195200
Py_XDECREF(tzinfo);
196201
return rv;
197202
}
@@ -232,17 +237,18 @@ typecast_PYDATETIMETZ_cast(const char *str, Py_ssize_t len, PyObject *curs)
232237
static PyObject *
233238
typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs)
234239
{
235-
PyObject* obj = NULL;
240+
PyObject* rv = NULL;
241+
PyObject *tzoff = NULL;
236242
PyObject *tzinfo = NULL;
237243
PyObject *tzinfo_factory;
238-
int n, hh=0, mm=0, ss=0, us=0, tz=0;
244+
int n, hh=0, mm=0, ss=0, us=0, tzsec=0;
239245

240246
if (str == NULL) { Py_RETURN_NONE; }
241247

242-
n = typecast_parse_time(str, NULL, &len, &hh, &mm, &ss, &us, &tz);
248+
n = typecast_parse_time(str, NULL, &len, &hh, &mm, &ss, &us, &tzsec);
243249
Dprintf("typecast_PYTIME_cast: n = %d, len = " FORMAT_CODE_PY_SSIZE_T ", "
244-
"hh = %d, mm = %d, ss = %d, us = %d, tz = %d",
245-
n, len, hh, mm, ss, us, tz);
250+
"hh = %d, mm = %d, ss = %d, us = %d, tzsec = %d",
251+
n, len, hh, mm, ss, us, tzsec);
246252

247253
if (n < 3 || n > 6) {
248254
PyErr_SetString(DataError, "unable to parse time");
@@ -254,25 +260,32 @@ typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs)
254260
}
255261
tzinfo_factory = ((cursorObject *)curs)->tzinfo_factory;
256262
if (n >= 5 && tzinfo_factory != Py_None) {
257-
/* we have a time zone, calculate minutes and create
263+
/* we have a time zone, calculate seconds and create
258264
appropriate tzinfo object calling the factory */
259-
Dprintf("typecast_PYTIME_cast: UTC offset = %ds", tz);
260-
261-
/* The datetime module requires that time zone offsets be
262-
a whole number of minutes, so truncate the seconds to the
263-
closest minute. */
264-
tzinfo = PyObject_CallFunction(tzinfo_factory, "i",
265-
(int)round(tz / 60.0));
266-
} else {
265+
Dprintf("typecast_PYTIME_cast: UTC offset = %ds", tzsec);
266+
267+
#if PY_VERSION_HEX < 0x03070000
268+
/* Before Python 3.7 the timezone offset had to be a whole number
269+
* of minutes, so round the seconds to the closest minute */
270+
tzsec = 60 * (int)round(tzsec / 60.0);
271+
#endif
272+
if (!(tzoff = PyDelta_FromDSU(0, tzsec, 0))) { goto exit; }
273+
if (!(tzinfo = PyObject_CallFunctionObjArgs(tzinfo_factory, tzoff, NULL))) {
274+
goto exit;
275+
}
276+
}
277+
else {
267278
Py_INCREF(Py_None);
268279
tzinfo = Py_None;
269280
}
270-
if (tzinfo != NULL) {
271-
obj = PyObject_CallFunction((PyObject*)PyDateTimeAPI->TimeType, "iiiiO",
272-
hh, mm, ss, us, tzinfo);
273-
Py_DECREF(tzinfo);
274-
}
275-
return obj;
281+
282+
rv = PyObject_CallFunction((PyObject*)PyDateTimeAPI->TimeType, "iiiiO",
283+
hh, mm, ss, us, tzinfo);
284+
285+
exit:
286+
Py_XDECREF(tzoff);
287+
Py_XDECREF(tzinfo);
288+
return rv;
276289
}
277290

278291

tests/test_dates.py

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
2424
# License for more details.
2525

26+
import sys
2627
import math
2728
import pickle
2829
from datetime import date, datetime, time, timedelta
@@ -157,17 +158,27 @@ def test_parse_time_timezone(self):
157158
self.check_time_tz("-01", -3600)
158159
self.check_time_tz("+01:15", 4500)
159160
self.check_time_tz("-01:15", -4500)
160-
# The Python datetime module does not support time zone
161-
# offsets that are not a whole number of minutes.
162-
# We round the offset to the nearest minute.
163-
self.check_time_tz("+01:15:00", 60 * (60 + 15))
164-
self.check_time_tz("+01:15:29", 60 * (60 + 15))
165-
self.check_time_tz("+01:15:30", 60 * (60 + 16))
166-
self.check_time_tz("+01:15:59", 60 * (60 + 16))
167-
self.check_time_tz("-01:15:00", -60 * (60 + 15))
168-
self.check_time_tz("-01:15:29", -60 * (60 + 15))
169-
self.check_time_tz("-01:15:30", -60 * (60 + 16))
170-
self.check_time_tz("-01:15:59", -60 * (60 + 16))
161+
if sys.version_info < (3, 7):
162+
# The Python < 3.7 datetime module does not support time zone
163+
# offsets that are not a whole number of minutes.
164+
# We round the offset to the nearest minute.
165+
self.check_time_tz("+01:15:00", 60 * (60 + 15))
166+
self.check_time_tz("+01:15:29", 60 * (60 + 15))
167+
self.check_time_tz("+01:15:30", 60 * (60 + 16))
168+
self.check_time_tz("+01:15:59", 60 * (60 + 16))
169+
self.check_time_tz("-01:15:00", -60 * (60 + 15))
170+
self.check_time_tz("-01:15:29", -60 * (60 + 15))
171+
self.check_time_tz("-01:15:30", -60 * (60 + 16))
172+
self.check_time_tz("-01:15:59", -60 * (60 + 16))
173+
else:
174+
self.check_time_tz("+01:15:00", 60 * (60 + 15))
175+
self.check_time_tz("+01:15:29", 60 * (60 + 15) + 29)
176+
self.check_time_tz("+01:15:30", 60 * (60 + 15) + 30)
177+
self.check_time_tz("+01:15:59", 60 * (60 + 15) + 59)
178+
self.check_time_tz("-01:15:00", -(60 * (60 + 15)))
179+
self.check_time_tz("-01:15:29", -(60 * (60 + 15) + 29))
180+
self.check_time_tz("-01:15:30", -(60 * (60 + 15) + 30))
181+
self.check_time_tz("-01:15:59", -(60 * (60 + 15) + 59))
171182

172183
def check_datetime_tz(self, str_offset, offset):
173184
base = datetime(2007, 1, 1, 13, 30, 29)
@@ -192,17 +203,27 @@ def test_parse_datetime_timezone(self):
192203
self.check_datetime_tz("-01", -3600)
193204
self.check_datetime_tz("+01:15", 4500)
194205
self.check_datetime_tz("-01:15", -4500)
195-
# The Python datetime module does not support time zone
196-
# offsets that are not a whole number of minutes.
197-
# We round the offset to the nearest minute.
198-
self.check_datetime_tz("+01:15:00", 60 * (60 + 15))
199-
self.check_datetime_tz("+01:15:29", 60 * (60 + 15))
200-
self.check_datetime_tz("+01:15:30", 60 * (60 + 16))
201-
self.check_datetime_tz("+01:15:59", 60 * (60 + 16))
202-
self.check_datetime_tz("-01:15:00", -60 * (60 + 15))
203-
self.check_datetime_tz("-01:15:29", -60 * (60 + 15))
204-
self.check_datetime_tz("-01:15:30", -60 * (60 + 16))
205-
self.check_datetime_tz("-01:15:59", -60 * (60 + 16))
206+
if sys.version_info < (3, 7):
207+
# The Python < 3.7 datetime module does not support time zone
208+
# offsets that are not a whole number of minutes.
209+
# We round the offset to the nearest minute.
210+
self.check_datetime_tz("+01:15:00", 60 * (60 + 15))
211+
self.check_datetime_tz("+01:15:29", 60 * (60 + 15))
212+
self.check_datetime_tz("+01:15:30", 60 * (60 + 16))
213+
self.check_datetime_tz("+01:15:59", 60 * (60 + 16))
214+
self.check_datetime_tz("-01:15:00", -60 * (60 + 15))
215+
self.check_datetime_tz("-01:15:29", -60 * (60 + 15))
216+
self.check_datetime_tz("-01:15:30", -60 * (60 + 16))
217+
self.check_datetime_tz("-01:15:59", -60 * (60 + 16))
218+
else:
219+
self.check_datetime_tz("+01:15:00", 60 * (60 + 15))
220+
self.check_datetime_tz("+01:15:29", 60 * (60 + 15) + 29)
221+
self.check_datetime_tz("+01:15:30", 60 * (60 + 15) + 30)
222+
self.check_datetime_tz("+01:15:59", 60 * (60 + 15) + 59)
223+
self.check_datetime_tz("-01:15:00", -(60 * (60 + 15)))
224+
self.check_datetime_tz("-01:15:29", -(60 * (60 + 15) + 29))
225+
self.check_datetime_tz("-01:15:30", -(60 * (60 + 15) + 30))
226+
self.check_datetime_tz("-01:15:59", -(60 * (60 + 15) + 59))
206227

207228
def test_parse_time_no_timezone(self):
208229
self.assertEqual(self.TIME("13:30:29", self.curs).tzinfo, None)
@@ -628,17 +649,27 @@ def test_init_with_no_args(self):
628649
def test_repr_with_positive_offset(self):
629650
tzinfo = FixedOffsetTimezone(5 * 60)
630651
self.assertEqual(repr(tzinfo),
631-
"psycopg2.tz.FixedOffsetTimezone(offset=300, name=None)")
652+
"psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)"
653+
% timedelta(minutes=5 * 60))
632654

633655
def test_repr_with_negative_offset(self):
634656
tzinfo = FixedOffsetTimezone(-5 * 60)
635657
self.assertEqual(repr(tzinfo),
636-
"psycopg2.tz.FixedOffsetTimezone(offset=-300, name=None)")
658+
"psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)"
659+
% timedelta(minutes=-5 * 60))
660+
661+
def test_init_with_timedelta(self):
662+
td = timedelta(minutes=5 * 60)
663+
tzinfo = FixedOffsetTimezone(td)
664+
self.assertEqual(tzinfo, FixedOffsetTimezone(5 * 60))
665+
self.assertEqual(repr(tzinfo),
666+
"psycopg2.tz.FixedOffsetTimezone(offset=%r, name=None)" % td)
637667

638668
def test_repr_with_name(self):
639669
tzinfo = FixedOffsetTimezone(name="FOO")
640670
self.assertEqual(repr(tzinfo),
641-
"psycopg2.tz.FixedOffsetTimezone(offset=0, name='FOO')")
671+
"psycopg2.tz.FixedOffsetTimezone(offset=%r, name='FOO')"
672+
% timedelta(0))
642673

643674
def test_instance_caching(self):
644675
self.assert_(FixedOffsetTimezone(name="FOO")

0 commit comments

Comments
 (0)