[Python-checkins] cpython (3.4): Issue #23517: Fix rounding in fromtimestamp() and utcfromtimestamp() methods

victor.stinner python-checkins at python.org
Fri Sep 18 14:58:29 CEST 2015


https://hg.python.org/cpython/rev/ee1cf1b188d2
changeset:   98048:ee1cf1b188d2
branch:      3.4
parent:      98031:9f57c937958f
user:        Victor Stinner <victor.stinner at gmail.com>
date:        Fri Sep 18 14:42:05 2015 +0200
summary:
  Issue #23517: Fix rounding in fromtimestamp() and utcfromtimestamp() methods
of datetime.datetime: microseconds are now rounded to nearest with ties going
to nearest even integer (ROUND_HALF_EVEN), instead of being rounding towards
zero (ROUND_DOWN). It's important that these methods use the same rounding
mode than datetime.timedelta to keep the property:

   (datetime(1970,1,1) + timedelta(seconds=t)) == datetime.utcfromtimestamp(t)

It also the rounding mode used by round(float) for example.

Add more unit tests on the rounding mode in test_datetime.

files:
  Lib/datetime.py            |  53 ++++++++----------
  Lib/test/datetimetester.py |  24 +++++++-
  Misc/NEWS                  |   8 ++
  Modules/_datetimemodule.c  |  73 ++++++++++++++++++++++---
  4 files changed, 115 insertions(+), 43 deletions(-)


diff --git a/Lib/datetime.py b/Lib/datetime.py
--- a/Lib/datetime.py
+++ b/Lib/datetime.py
@@ -1362,49 +1362,42 @@
         return self._tzinfo
 
     @classmethod
+    def _fromtimestamp(cls, t, utc, tz):
+        """Construct a datetime from a POSIX timestamp (like time.time()).
+
+        A timezone info object may be passed in as well.
+        """
+        frac, t = _math.modf(t)
+        us = round(frac * 1e6)
+        if us >= 1000000:
+            t += 1
+            us -= 1000000
+        elif us < 0:
+            t -= 1
+            us += 1000000
+
+        converter = _time.gmtime if utc else _time.localtime
+        y, m, d, hh, mm, ss, weekday, jday, dst = converter(t)
+        ss = min(ss, 59)    # clamp out leap seconds if the platform has them
+        return cls(y, m, d, hh, mm, ss, us, tz)
+
+    @classmethod
     def fromtimestamp(cls, t, tz=None):
         """Construct a datetime from a POSIX timestamp (like time.time()).
 
         A timezone info object may be passed in as well.
         """
-
         _check_tzinfo_arg(tz)
 
-        converter = _time.localtime if tz is None else _time.gmtime
-
-        t, frac = divmod(t, 1.0)
-        us = int(frac * 1e6)
-
-        # If timestamp is less than one microsecond smaller than a
-        # full second, us can be rounded up to 1000000.  In this case,
-        # roll over to seconds, otherwise, ValueError is raised
-        # by the constructor.
-        if us == 1000000:
-            t += 1
-            us = 0
-        y, m, d, hh, mm, ss, weekday, jday, dst = converter(t)
-        ss = min(ss, 59)    # clamp out leap seconds if the platform has them
-        result = cls(y, m, d, hh, mm, ss, us, tz)
+        result = cls._fromtimestamp(t, tz is not None, tz)
         if tz is not None:
             result = tz.fromutc(result)
         return result
 
     @classmethod
     def utcfromtimestamp(cls, t):
-        "Construct a UTC datetime from a POSIX timestamp (like time.time())."
-        t, frac = divmod(t, 1.0)
-        us = int(frac * 1e6)
-
-        # If timestamp is less than one microsecond smaller than a
-        # full second, us can be rounded up to 1000000.  In this case,
-        # roll over to seconds, otherwise, ValueError is raised
-        # by the constructor.
-        if us == 1000000:
-            t += 1
-            us = 0
-        y, m, d, hh, mm, ss, weekday, jday, dst = _time.gmtime(t)
-        ss = min(ss, 59)    # clamp out leap seconds if the platform has them
-        return cls(y, m, d, hh, mm, ss, us)
+        """Construct a naive UTC datetime from a POSIX timestamp."""
+        return cls._fromtimestamp(t, True, None)
 
     # XXX This is supposed to do better than we *can* do by using time.time(),
     # XXX if the platform supports a more accurate way.  The C implementation
diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -650,8 +650,16 @@
         # Single-field rounding.
         eq(td(milliseconds=0.4/1000), td(0))    # rounds to 0
         eq(td(milliseconds=-0.4/1000), td(0))    # rounds to 0
+        eq(td(milliseconds=0.5/1000), td(microseconds=0))
+        eq(td(milliseconds=-0.5/1000), td(microseconds=-0))
         eq(td(milliseconds=0.6/1000), td(microseconds=1))
         eq(td(milliseconds=-0.6/1000), td(microseconds=-1))
+        eq(td(milliseconds=1.5/1000), td(microseconds=2))
+        eq(td(milliseconds=-1.5/1000), td(microseconds=-2))
+        eq(td(seconds=0.5/10**6), td(microseconds=0))
+        eq(td(seconds=-0.5/10**6), td(microseconds=-0))
+        eq(td(seconds=1/2**7), td(microseconds=7812))
+        eq(td(seconds=-1/2**7), td(microseconds=-7812))
 
         # Rounding due to contributions from more than one field.
         us_per_hour = 3600e6
@@ -1824,12 +1832,14 @@
                           tzinfo=timezone(timedelta(hours=-5), 'EST'))
         self.assertEqual(t.timestamp(),
                          18000 + 3600 + 2*60 + 3 + 4*1e-6)
+
     def test_microsecond_rounding(self):
         for fts in [self.theclass.fromtimestamp,
                     self.theclass.utcfromtimestamp]:
             zero = fts(0)
             self.assertEqual(zero.second, 0)
             self.assertEqual(zero.microsecond, 0)
+            one = fts(1e-6)
             try:
                 minus_one = fts(-1e-6)
             except OSError:
@@ -1840,22 +1850,28 @@
                 self.assertEqual(minus_one.microsecond, 999999)
 
                 t = fts(-1e-8)
-                self.assertEqual(t, minus_one)
+                self.assertEqual(t, zero)
                 t = fts(-9e-7)
                 self.assertEqual(t, minus_one)
                 t = fts(-1e-7)
-                self.assertEqual(t, minus_one)
+                self.assertEqual(t, zero)
+                t = fts(-1/2**7)
+                self.assertEqual(t.second, 59)
+                self.assertEqual(t.microsecond, 992188)
 
             t = fts(1e-7)
             self.assertEqual(t, zero)
             t = fts(9e-7)
-            self.assertEqual(t, zero)
+            self.assertEqual(t, one)
             t = fts(0.99999949)
             self.assertEqual(t.second, 0)
             self.assertEqual(t.microsecond, 999999)
             t = fts(0.9999999)
+            self.assertEqual(t.second, 1)
+            self.assertEqual(t.microsecond, 0)
+            t = fts(1/2**7)
             self.assertEqual(t.second, 0)
-            self.assertEqual(t.microsecond, 999999)
+            self.assertEqual(t.microsecond, 7812)
 
     def test_insane_fromtimestamp(self):
         # It's possible that some platform maps time_t to double,
diff --git a/Misc/NEWS b/Misc/NEWS
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -81,6 +81,14 @@
 Library
 -------
 
+- Issue #23517: Fix rounding in fromtimestamp() and utcfromtimestamp() methods
+  of datetime.datetime: microseconds are now rounded to nearest with ties
+  going to nearest even integer (ROUND_HALF_EVEN), instead of being rounding
+  towards zero (ROUND_DOWN). It's important that these methods use the same
+  rounding mode than datetime.timedelta to keep the property:
+  (datetime(1970,1,1) + timedelta(seconds=t)) == datetime.utcfromtimestamp(t).
+  It also the rounding mode used by round(float) for example.
+
 - Issue #24684: socket.socket.getaddrinfo() now calls
   PyUnicode_AsEncodedString() instead of calling the encode() method of the
   host, to handle correctly custom string with an encode() method which doesn't
diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c
--- a/Modules/_datetimemodule.c
+++ b/Modules/_datetimemodule.c
@@ -4113,6 +4113,44 @@
                                  tzinfo);
 }
 
+static time_t
+_PyTime_DoubleToTimet(double x)
+{
+    time_t result;
+    double diff;
+
+    result = (time_t)x;
+    /* How much info did we lose?  time_t may be an integral or
+     * floating type, and we don't know which.  If it's integral,
+     * we don't know whether C truncates, rounds, returns the floor,
+     * etc.  If we lost a second or more, the C rounding is
+     * unreasonable, or the input just doesn't fit in a time_t;
+     * call it an error regardless.  Note that the original cast to
+     * time_t can cause a C error too, but nothing we can do to
+     * worm around that.
+     */
+    diff = x - (double)result;
+    if (diff <= -1.0 || diff >= 1.0) {
+        PyErr_SetString(PyExc_OverflowError,
+                        "timestamp out of range for platform time_t");
+        result = (time_t)-1;
+    }
+    return result;
+}
+
+/* Round a double to the nearest long.  |x| must be small enough to fit
+ * in a C long; this is not checked.
+ */
+static double
+_PyTime_RoundHalfEven(double x)
+{
+    double rounded = round(x);
+    if (fabs(x-rounded) == 0.5)
+        /* halfway case: round to even */
+        rounded = 2.0*round(x/2.0);
+    return rounded;
+}
+
 /* Internal helper.
  * Build datetime from a Python timestamp.  Pass localtime or gmtime for f,
  * to control the interpretation of the timestamp.  Since a double doesn't
@@ -4121,15 +4159,32 @@
  * to get that much precision (e.g., C time() isn't good enough).
  */
 static PyObject *
-datetime_from_timestamp(PyObject *cls, TM_FUNC f, PyObject *timestamp,
+datetime_from_timestamp(PyObject *cls, TM_FUNC f, double timestamp,
                         PyObject *tzinfo)
 {
     time_t timet;
-    long us;
-
-    if (_PyTime_ObjectToTimeval(timestamp, &timet, &us, _PyTime_ROUND_DOWN) == -1)
+    double fraction;
+    int us;
+
+    timet = _PyTime_DoubleToTimet(timestamp);
+    if (timet == (time_t)-1 && PyErr_Occurred())
         return NULL;
-    return datetime_from_timet_and_us(cls, f, timet, (int)us, tzinfo);
+    fraction = timestamp - (double)timet;
+    us = (int)_PyTime_RoundHalfEven(fraction * 1e6);
+    if (us < 0) {
+        /* Truncation towards zero is not what we wanted
+           for negative numbers (Python's mod semantics) */
+        timet -= 1;
+        us += 1000000;
+    }
+    /* If timestamp is less than one microsecond smaller than a
+     * full second, round up. Otherwise, ValueErrors are raised
+     * for some floats. */
+    if (us == 1000000) {
+        timet += 1;
+        us = 0;
+    }
+    return datetime_from_timet_and_us(cls, f, timet, us, tzinfo);
 }
 
 /* Internal helper.
@@ -4231,11 +4286,11 @@
 datetime_fromtimestamp(PyObject *cls, PyObject *args, PyObject *kw)
 {
     PyObject *self;
-    PyObject *timestamp;
+    double timestamp;
     PyObject *tzinfo = Py_None;
     static char *keywords[] = {"timestamp", "tz", NULL};
 
-    if (! PyArg_ParseTupleAndKeywords(args, kw, "O|O:fromtimestamp",
+    if (! PyArg_ParseTupleAndKeywords(args, kw, "d|O:fromtimestamp",
                                       keywords, &timestamp, &tzinfo))
         return NULL;
     if (check_tzinfo_subclass(tzinfo) < 0)
@@ -4259,10 +4314,10 @@
 static PyObject *
 datetime_utcfromtimestamp(PyObject *cls, PyObject *args)
 {
-    PyObject *timestamp;
+    double timestamp;
     PyObject *result = NULL;
 
-    if (PyArg_ParseTuple(args, "O:utcfromtimestamp", &timestamp))
+    if (PyArg_ParseTuple(args, "d:utcfromtimestamp", &timestamp))
         result = datetime_from_timestamp(cls, gmtime, timestamp,
                                          Py_None);
     return result;

-- 
Repository URL: https://hg.python.org/cpython


More information about the Python-checkins mailing list