From 2a94dfae47c10426be7e9b7babdf89548cfbe24d Mon Sep 17 00:00:00 2001 From: James Henstridge Date: Tue, 17 Feb 2009 15:03:33 +0900 Subject: [PATCH] * tests/test_dates.py (DatetimeTests, mxDateTimeTests): full test coverage for datetime and time strings with and without time zone information. * psycopg/typecast_datetime.c (typecast_PYDATETIME_cast): adjust to handle the changes in typecast_parse_time. (typecast_PYTIME_cast): add support for time zone aware time values. * psycopg/typecast_mxdatetime.c (typecast_MXDATE_cast): make sure that values with time zones are correctly processed (even though that means ignoring the time zone value). (typecast_MXTIME_cast): same here. * psycopg/typecast.c (typecast_parse_time): Update method to parse second resolution timezone offsets. --- ChangeLog | 17 +++ psycopg/typecast.c | 12 +- psycopg/typecast_datetime.c | 70 +++++++++--- psycopg/typecast_mxdatetime.c | 4 +- tests/test_dates.py | 200 ++++++++++++++++++++++++---------- 5 files changed, 222 insertions(+), 81 deletions(-) diff --git a/ChangeLog b/ChangeLog index b8a57238..b89be49e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,22 @@ 2009-02-17 James Henstridge + * tests/test_dates.py (DatetimeTests, mxDateTimeTests): full test + coverage for datetime and time strings with and without time zone + information. + + * psycopg/typecast_datetime.c (typecast_PYDATETIME_cast): adjust + to handle the changes in typecast_parse_time. + (typecast_PYTIME_cast): add support for time zone aware time + values. + + * psycopg/typecast_mxdatetime.c (typecast_MXDATE_cast): make sure + that values with time zones are correctly processed (even though + that means ignoring the time zone value). + (typecast_MXTIME_cast): same here. + + * psycopg/typecast.c (typecast_parse_time): Update method to parse + second resolution timezone offsets. + * psycopg/typecast.c (typecast_parse_time): Fix up handling of negative timezone offsets with a non-zero minutes field. diff --git a/psycopg/typecast.c b/psycopg/typecast.c index a21b30f6..07bb1f5f 100644 --- a/psycopg/typecast.c +++ b/psycopg/typecast.c @@ -96,7 +96,7 @@ typecast_parse_time(const char* s, const char** t, Py_ssize_t* len, int* hh, int* mm, int* ss, int* us, int* tz) { int acc = -1, cz = 0; - int tzs = 1, tzhh = 0, tzmm = 0; + int tzsign = 1, tzhh = 0, tzmm = 0, tzss = 0; int usd = 0; /* sets microseconds and timezone to 0 because they may be missing */ @@ -105,7 +105,7 @@ typecast_parse_time(const char* s, const char** t, Py_ssize_t* len, Dprintf("typecast_parse_time: len = " FORMAT_CODE_PY_SSIZE_T ", s = %s", *len, s); - while (cz < 6 && *len > 0 && *s) { + while (cz < 7 && *len > 0 && *s) { switch (*s) { case ':': if (cz == 0) *hh = acc; @@ -113,6 +113,7 @@ typecast_parse_time(const char* s, const char** t, Py_ssize_t* len, else if (cz == 2) *ss = acc; else if (cz == 3) *us = acc; else if (cz == 4) tzhh = acc; + else if (cz == 5) tzmm = acc; acc = -1; cz++; break; case '.': @@ -125,7 +126,7 @@ typecast_parse_time(const char* s, const char** t, Py_ssize_t* len, case '-': /* seconds or microseconds here, anything else is an error */ if (cz < 2 || cz > 3) return -1; - if (*s == '-') tzs = -1; + if (*s == '-') tzsign = -1; if (cz == 2) *ss = acc; else if (cz == 3) *us = acc; acc = -1; cz = 4; @@ -151,11 +152,12 @@ typecast_parse_time(const char* s, const char** t, Py_ssize_t* len, else if (cz == 2) { *ss = acc; cz += 1; } else if (cz == 3) { *us = acc; cz += 1; } else if (cz == 4) { tzhh = acc; cz += 1; } - else if (cz == 5) tzmm = acc; + else if (cz == 5) { tzmm = acc; cz += 1; } + else if (cz == 6) tzss = acc; } if (t != NULL) *t = s; - *tz = tzs * (tzhh * 60 + tzmm); + *tz = tzsign * (3600 * tzhh + 60 * tzmm + tzss); if (*us != 0) { while (usd++ < 6) *us *= 10; diff --git a/psycopg/typecast_datetime.c b/psycopg/typecast_datetime.c index 8144850b..02560323 100644 --- a/psycopg/typecast_datetime.c +++ b/psycopg/typecast_datetime.c @@ -74,6 +74,8 @@ static PyObject * typecast_PYDATETIME_cast(const char *str, Py_ssize_t len, PyObject *curs) { PyObject* obj = 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; const char *tp = NULL; @@ -108,7 +110,7 @@ typecast_PYDATETIME_cast(const char *str, Py_ssize_t len, PyObject *curs) " len = " FORMAT_CODE_PY_SSIZE_T "," " hh = %d, mm = %d, ss = %d, us = %d, tz = %d", n, len, hh, mm, ss, us, tz); - if (n < 3 || n > 5) { + if (n < 3 || n > 6) { PyErr_SetString(DataError, "unable to parse time"); return NULL; } @@ -121,24 +123,34 @@ typecast_PYDATETIME_cast(const char *str, Py_ssize_t len, PyObject *curs) if (y > 9999) y = 9999; - if (n == 5 && ((cursorObject*)curs)->tzinfo_factory != Py_None) { + tzinfo_factory = ((cursorObject *)curs)->tzinfo_factory; + if (n >= 5 && tzinfo_factory != Py_None) { /* we have a time zone, calculate minutes and create appropriate tzinfo object calling the factory */ - PyObject *tzinfo; - Dprintf("typecast_PYDATETIME_cast: UTC offset = %dm", tz); - tzinfo = PyObject_CallFunction( - ((cursorObject*)curs)->tzinfo_factory, "i", tz); + Dprintf("typecast_PYDATETIME_cast: UTC offset = %ds", tz); + + /* The datetime module requires that time zone offsets be + a whole number of minutes, so fail if we have a time + zone with a seconds offset. + */ + if (tz % 60 != 0) { + PyErr_Format(PyExc_ValueError, "time zone offset %d is not " + "a whole number of minutes", tz); + return NULL; + } + tzinfo = PyObject_CallFunction(tzinfo_factory, "i", tz / 60); + } else { + Py_INCREF(Py_None); + tzinfo = Py_None; + } + if (tzinfo != NULL) { obj = PyObject_CallFunction(pyDateTimeTypeP, "iiiiiiiO", y, m, d, hh, mm, ss, us, tzinfo); Dprintf("typecast_PYDATETIME_cast: tzinfo: %p, refcnt = " FORMAT_CODE_PY_SSIZE_T, tzinfo, tzinfo->ob_refcnt ); - Py_XDECREF(tzinfo); - } - else { - obj = PyObject_CallFunction(pyDateTimeTypeP, "iiiiiii", - y, m, d, hh, mm, ss, us); + Py_DECREF(tzinfo); } } return obj; @@ -150,6 +162,8 @@ static PyObject * typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs) { PyObject* obj = NULL; + PyObject *tzinfo = NULL; + PyObject *tzinfo_factory; int n, hh=0, mm=0, ss=0, us=0, tz=0; if (str == NULL) {Py_INCREF(Py_None); return Py_None;} @@ -159,16 +173,38 @@ typecast_PYTIME_cast(const char *str, Py_ssize_t len, PyObject *curs) "hh = %d, mm = %d, ss = %d, us = %d, tz = %d", n, len, hh, mm, ss, us, tz); - if (n < 3 || n > 5) { + if (n < 3 || n > 6) { PyErr_SetString(DataError, "unable to parse time"); return NULL; } - else { - if (ss > 59) { - mm += 1; - ss -= 60; + if (ss > 59) { + mm += 1; + ss -= 60; + } + tzinfo_factory = ((cursorObject *)curs)->tzinfo_factory; + if (n >= 5 && tzinfo_factory != Py_None) { + /* we have a time zone, calculate minutes 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 fail if we have a time + zone with a seconds offset. + */ + if (tz % 60 != 0) { + PyErr_Format(PyExc_ValueError, "time zone offset %d is not " + "a whole number of minutes", tz); + return NULL; } - obj = PyObject_CallFunction(pyTimeTypeP, "iiii", hh, mm, ss, us); + tzinfo = PyObject_CallFunction(tzinfo_factory, "i", tz / 60); + } else { + Py_INCREF(Py_None); + tzinfo = Py_None; + } + if (tzinfo != NULL) { + obj = PyObject_CallFunction(pyTimeTypeP, "iiiiO", + hh, mm, ss, us, tzinfo); + Py_DECREF(tzinfo); } return obj; } diff --git a/psycopg/typecast_mxdatetime.c b/psycopg/typecast_mxdatetime.c index e030091d..eac220ff 100644 --- a/psycopg/typecast_mxdatetime.c +++ b/psycopg/typecast_mxdatetime.c @@ -63,7 +63,7 @@ typecast_MXDATE_cast(const char *str, Py_ssize_t len, PyObject *curs) " len = " FORMAT_CODE_PY_SSIZE_T "," " hh = %d, mm = %d, ss = %d, us = %d, tz = %d", n, len, hh, mm, ss, us, tz); - if (n != 0 && (n < 3 || n > 5)) { + if (n != 0 && (n < 3 || n > 6)) { PyErr_SetString(DataError, "unable to parse time"); return NULL; } @@ -91,7 +91,7 @@ typecast_MXTIME_cast(const char *str, Py_ssize_t len, PyObject *curs) Dprintf("typecast_MXTIME_cast: hh = %d, mm = %d, ss = %d, us = %d", hh, mm, ss, us); - if (n < 3 || n > 5) { + if (n < 3 || n > 6) { PyErr_SetString(DataError, "unable to parse time"); return NULL; } diff --git a/tests/test_dates.py b/tests/test_dates.py index eda4e893..7b9e59fe 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -10,43 +10,41 @@ import tests class CommonDatetimeTestsMixin: def execute(self, *args): - conn = psycopg2.connect(tests.dsn) - curs = conn.cursor() - curs.execute(*args) - return curs.fetchone()[0] + self.curs.execute(*args) + return self.curs.fetchone()[0] def test_parse_date(self): - value = self.DATE('2007-01-01', None) + value = self.DATE('2007-01-01', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.year, 2007) self.assertEqual(value.month, 1) self.assertEqual(value.day, 1) def test_parse_null_date(self): - value = self.DATE(None, None) + value = self.DATE(None, self.curs) self.assertEqual(value, None) def test_parse_incomplete_date(self): - self.assertRaises(psycopg2.DataError, self.DATE, '2007', None) - self.assertRaises(psycopg2.DataError, self.DATE, '2007-01', None) + self.assertRaises(psycopg2.DataError, self.DATE, '2007', self.curs) + self.assertRaises(psycopg2.DataError, self.DATE, '2007-01', self.curs) def test_parse_time(self): - value = self.TIME('13:30:29', None) + value = self.TIME('13:30:29', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.hour, 13) self.assertEqual(value.minute, 30) self.assertEqual(value.second, 29) def test_parse_null_time(self): - value = self.TIME(None, None) + value = self.TIME(None, self.curs) self.assertEqual(value, None) def test_parse_incomplete_time(self): - self.assertRaises(psycopg2.DataError, self.TIME, '13', None) - self.assertRaises(psycopg2.DataError, self.TIME, '13:30', None) + self.assertRaises(psycopg2.DataError, self.TIME, '13', self.curs) + self.assertRaises(psycopg2.DataError, self.TIME, '13:30', self.curs) def test_parse_datetime(self): - value = self.DATETIME('2007-01-01 13:30:29', None) + value = self.DATETIME('2007-01-01 13:30:29', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.year, 2007) self.assertEqual(value.month, 1) @@ -56,23 +54,21 @@ class CommonDatetimeTestsMixin: self.assertEqual(value.second, 29) def test_parse_null_datetime(self): - value = self.DATETIME(None, None) + value = self.DATETIME(None, self.curs) self.assertEqual(value, None) def test_parse_incomplete_time(self): self.assertRaises(psycopg2.DataError, - self.DATETIME, '2007', None) + self.DATETIME, '2007', self.curs) self.assertRaises(psycopg2.DataError, - self.DATETIME, '2007-01', None) + self.DATETIME, '2007-01', self.curs) self.assertRaises(psycopg2.DataError, - self.DATETIME, '2007-01-01 13', None) + self.DATETIME, '2007-01-01 13', self.curs) self.assertRaises(psycopg2.DataError, - self.DATETIME, '2007-01-01 13:30', None) - self.assertRaises(psycopg2.DataError, - self.DATETIME, '2007-01-01 13:30:29+00:10:50', None) + self.DATETIME, '2007-01-01 13:30', self.curs) def test_parse_null_interval(self): - value = self.INTERVAL(None, None) + value = self.INTERVAL(None, self.curs) self.assertEqual(value, None) @@ -80,80 +76,137 @@ class DatetimeTests(unittest.TestCase, CommonDatetimeTestsMixin): """Tests for the datetime based date handling in psycopg2.""" def setUp(self): + self.conn = psycopg2.connect(tests.dsn) + self.curs = self.conn.cursor() self.DATE = psycopg2._psycopg.PYDATE self.TIME = psycopg2._psycopg.PYTIME self.DATETIME = psycopg2._psycopg.PYDATETIME self.INTERVAL = psycopg2._psycopg.PYINTERVAL + def tearDown(self): + self.conn.close() + def test_parse_bc_date(self): # datetime does not support BC dates - self.assertRaises(ValueError, self.DATE, '00042-01-01 BC', None) + self.assertRaises(ValueError, self.DATE, '00042-01-01 BC', self.curs) def test_parse_bc_datetime(self): # datetime does not support BC dates self.assertRaises(ValueError, self.DATETIME, - '00042-01-01 13:30:29 BC', None) + '00042-01-01 13:30:29 BC', self.curs) def test_parse_time_microseconds(self): - value = self.TIME('13:30:29.123456', None) + value = self.TIME('13:30:29.123456', self.curs) self.assertEqual(value.second, 29) self.assertEqual(value.microsecond, 123456) def test_parse_datetime_microseconds(self): - value = self.DATETIME('2007-01-01 13:30:29.123456', None) + value = self.DATETIME('2007-01-01 13:30:29.123456', self.curs) self.assertEqual(value.second, 29) self.assertEqual(value.microsecond, 123456) - def check_timezone(self, curs, str_offset, offset): + def check_time_tz(self, str_offset, offset): + from datetime import time, timedelta + base = time(13, 30, 29) + base_str = '13:30:29' + + value = self.TIME(base_str + str_offset, self.curs) + + # Value has time zone info and correct UTC offset. + self.assertNotEqual(value.tzinfo, None), + self.assertEqual(value.utcoffset(), timedelta(seconds=offset)) + + # Time portion is correct. + self.assertEqual(value.replace(tzinfo=None), base) + + def test_parse_time_timezone(self): + self.check_time_tz("+01", 3600) + 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, so we get an + # error here. Check that we are generating an understandable + # error message. + try: + self.check_time_tz("+01:15:42", 4542) + except ValueError, exc: + self.assertEqual(exc.message, "time zone offset 4542 is not a " + "whole number of minutes") + else: + self.fail("Expected ValueError") + + try: + self.check_time_tz("-01:15:42", -4542) + except ValueError, exc: + self.assertEqual(exc.message, "time zone offset -4542 is not a " + "whole number of minutes") + else: + self.fail("Expected ValueError") + + def check_datetime_tz(self, str_offset, offset): from datetime import datetime, timedelta base = datetime(2007, 1, 1, 13, 30, 29) base_str = '2007-01-01 13:30:29' - value = self.DATETIME(base_str + str_offset, curs) + value = self.DATETIME(base_str + str_offset, self.curs) - # tzinfo instance is correct. - self.assertTrue(isinstance(value.tzinfo, FixedOffsetTimezone), - "value's timezone is not a FixedOffsetTimezone") - self.assertEqual(value.tzinfo._offset, timedelta(seconds=offset)) + # Value has time zone info and correct UTC offset. + self.assertNotEqual(value.tzinfo, None), + self.assertEqual(value.utcoffset(), timedelta(seconds=offset)) # Datetime is correct. self.assertEqual(value.replace(tzinfo=None), base) - # Offset from UTC is correct. + # Conversion to UTC produces the expected offset. UTC = FixedOffsetTimezone(0, "UTC") value_utc = value.astimezone(UTC).replace(tzinfo=None) self.assertEqual(base - value_utc, timedelta(seconds=offset)) - def test_parse_datetime_timezone_hours(self): - conn = psycopg2.connect(tests.dsn) - curs = conn.cursor() - self.check_timezone(curs, "+01", 3600) - self.check_timezone(curs, "-01", -3600) + def test_parse_datetime_timezone(self): + self.check_datetime_tz("+01", 3600) + 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, so we get an + # error here. Check that we are generating an understandable + # error message. + try: + self.check_datetime_tz("+01:15:42", 4542) + except ValueError, exc: + self.assertEqual(exc.message, "time zone offset 4542 is not a " + "whole number of minutes") + else: + self.fail("Expected ValueError") - def test_parse_datetime_timezone_hours_minutes(self): - conn = psycopg2.connect(tests.dsn) - curs = conn.cursor() - self.check_timezone(curs, "+01:15", 4500) - self.check_timezone(curs, "-01:15", -4500) + try: + self.check_datetime_tz("-01:15:42", -4542) + except ValueError, exc: + self.assertEqual(exc.message, "time zone offset -4542 is not a " + "whole number of minutes") + else: + self.fail("Expected ValueError") - # This test is disabled because we don't support parsing second - # resolution timezone offsets and Python wouldn't handle them even - # if we did. - def disabled_test_parse_datetime_timezone_hours_minutes_seconds(self): - conn = psycopg2.connect(tests.dsn) - curs = conn.cursor() - self.check_timezone(curs, "+01:15:42", 4542) - self.check_timezone(curs, "-01:15:42", -4542) + def test_parse_time_no_timezone(self): + self.assertEqual(self.TIME("13:30:29", self.curs).tzinfo, None) + self.assertEqual(self.TIME("13:30:29.123456", self.curs).tzinfo, None) + + def test_parse_datetime_no_timezone(self): + self.assertEqual( + self.DATETIME("2007-01-01 13:30:29", self.curs).tzinfo, None) + self.assertEqual( + self.DATETIME("2007-01-01 13:30:29.123456", self.curs).tzinfo, None) def test_parse_interval(self): - value = self.INTERVAL('42 days 12:34:56.123456', None) + value = self.INTERVAL('42 days 12:34:56.123456', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.days, 42) self.assertEqual(value.seconds, 45296) self.assertEqual(value.microseconds, 123456) def test_parse_negative_interval(self): - value = self.INTERVAL('-42 days -12:34:56.123456', None) + value = self.INTERVAL('-42 days -12:34:56.123456', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.days, -43) self.assertEqual(value.seconds, 41103) @@ -205,13 +258,18 @@ class mxDateTimeTests(unittest.TestCase, CommonDatetimeTestsMixin): """Tests for the mx.DateTime based date handling in psycopg2.""" def setUp(self): + self.conn = psycopg2.connect(tests.dsn) + self.curs = self.conn.cursor() self.DATE = psycopg2._psycopg.MXDATE self.TIME = psycopg2._psycopg.MXTIME self.DATETIME = psycopg2._psycopg.MXDATETIME self.INTERVAL = psycopg2._psycopg.MXINTERVAL + def tearDown(self): + self.conn.close() + def test_parse_bc_date(self): - value = self.DATE('00042-01-01 BC', None) + value = self.DATE('00042-01-01 BC', self.curs) self.assertNotEqual(value, None) # mx.DateTime numbers BC dates from 0 rather than 1. self.assertEqual(value.year, -41) @@ -219,7 +277,7 @@ class mxDateTimeTests(unittest.TestCase, CommonDatetimeTestsMixin): self.assertEqual(value.day, 1) def test_parse_bc_datetime(self): - value = self.DATETIME('00042-01-01 13:30:29 BC', None) + value = self.DATETIME('00042-01-01 13:30:29 BC', self.curs) self.assertNotEqual(value, None) # mx.DateTime numbers BC dates from 0 rather than 1. self.assertEqual(value.year, -41) @@ -230,19 +288,47 @@ class mxDateTimeTests(unittest.TestCase, CommonDatetimeTestsMixin): self.assertEqual(value.second, 29) def test_parse_time_microseconds(self): - value = self.TIME('13:30:29.123456', None) + value = self.TIME('13:30:29.123456', self.curs) self.assertEqual(math.floor(value.second), 29) self.assertEqual( int((value.second - math.floor(value.second)) * 1000000), 123456) def test_parse_datetime_microseconds(self): - value = self.DATETIME('2007-01-01 13:30:29.123456', None) + value = self.DATETIME('2007-01-01 13:30:29.123456', self.curs) self.assertEqual(math.floor(value.second), 29) self.assertEqual( int((value.second - math.floor(value.second)) * 1000000), 123456) + def test_parse_time_timezone(self): + # Time zone information is ignored. + from mx.DateTime import Time + expected = Time(13, 30, 29) + self.assertEqual(expected, self.TIME("13:30:29+01", self.curs)) + self.assertEqual(expected, self.TIME("13:30:29-01", self.curs)) + self.assertEqual(expected, self.TIME("13:30:29+01:15", self.curs)) + self.assertEqual(expected, self.TIME("13:30:29-01:15", self.curs)) + self.assertEqual(expected, self.TIME("13:30:29+01:15:42", self.curs)) + self.assertEqual(expected, self.TIME("13:30:29-01:15:42", self.curs)) + + def test_parse_datetime_timezone(self): + # Time zone information is ignored. + from mx.DateTime import DateTime + expected = DateTime(2007, 1, 1, 13, 30, 29) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29+01", self.curs)) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29-01", self.curs)) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29+01:15", self.curs)) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29-01:15", self.curs)) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29+01:15:42", self.curs)) + self.assertEqual( + expected, self.DATETIME("2007-01-01 13:30:29-01:15:42", self.curs)) + def test_parse_interval(self): - value = self.INTERVAL('42 days 05:50:05', None) + value = self.INTERVAL('42 days 05:50:05', self.curs) self.assertNotEqual(value, None) self.assertEqual(value.day, 42) self.assertEqual(value.hour, 5)