Skip to content

Commit 5e443ec

Browse files
author
Jeff Whitaker
authored
Merge pull request #81 from bjlittle/fix-richcmp
Fix cftime.datetime.__richcmp__
2 parents a9bda59 + b1857b3 commit 5e443ec

2 files changed

Lines changed: 95 additions & 12 deletions

File tree

cftime/_cftime.pyx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Performs conversions of netCDF time coordinate data to/from datetime objects.
33
"""
44

5-
from cpython.object cimport PyObject_RichCompare
5+
from cpython.object cimport (PyObject_RichCompare, Py_LT, Py_LE, Py_EQ,
6+
Py_NE, Py_GT, Py_GE)
67
import cython
78
import numpy as np
89
import re
@@ -37,6 +38,9 @@ cdef int[12] _dpm_360 = [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
3738
# Same as above, but SUM of previous months (no leap years).
3839
cdef int[13] _spm_365day = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]
3940
cdef int[13] _spm_366day = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]
41+
# Reverse operator lookup for datetime.__richcmp__
42+
_rop_lookup = {Py_LT: '__gt__', Py_LE: '__ge__', Py_EQ: '__eq__',
43+
Py_GT: '__lt__', Py_GE: '__le__', Py_NE: '__ne__'}
4044

4145
__version__ = '1.0.2.1'
4246

@@ -1272,16 +1276,29 @@ Gregorial calendar.
12721276
raise TypeError("cannot compare {0!r} and {1!r} (different calendars)".format(self, other))
12731277
return PyObject_RichCompare(dt.to_tuple(), to_tuple(other), op)
12741278
else:
1275-
# With Python3 we can use "return NotImplemented". If the other
1276-
# object does not have rich comparison instructions for cftime
1277-
# then a TypeError is automatically raised. With Python2 in this
1278-
# scenario the default behaviour is to compare the object ids
1279-
# which will always have a result. Therefore there is no way to
1280-
# differentiate between objects that do or do not have legitimate
1281-
# comparisons, and so we cannot remove the TypeError below.
1282-
if sys.version_info[0] < 3:
1283-
raise TypeError("cannot compare {0!r} and {1!r}".format(self, other))
1279+
# With Python3 we can simply return NotImplemented. If the other
1280+
# object does not support rich comparison for cftime then a
1281+
# TypeError will be automatically raised. However, Python2 is not
1282+
# consistent with this Python3 behaviour. In Python2, we only
1283+
# delegate the comparison operation to the other object iff it has
1284+
# suitable rich comparison support available. This is deduced by
1285+
# introspection of the other object. Otherwise, we explicitly raise
1286+
# a TypeError to avoid Python2 defaulting to using either __cmp__
1287+
# comparision on the other object, or worst still, object ID
1288+
# comparison. Either way, at this point the comparision is deemed
1289+
# not valid from our perspective.
1290+
if sys.version_info.major == 2:
1291+
rop = _rop_lookup[op]
1292+
if (hasattr(other, '__richcmp__') or hasattr(other, rop)):
1293+
# The other object potentially has the smarts to handle
1294+
# the comparision, so allow the Python machinery to hand
1295+
# the operation off to the other object.
1296+
return NotImplemented
1297+
# Otherwise, the comparison is not valid.
1298+
emsg = "cannot compare {0!r} and {1!r}"
1299+
raise TypeError(emsg.format(self, other))
12841300
else:
1301+
# Delegate responsibility of comparison to the other object.
12851302
return NotImplemented
12861303

12871304
cdef _getstate(self):

test/test_cftime.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import print_function
2+
23
import copy
4+
import operator
5+
import sys
36
import unittest
47
from collections import namedtuple
58
from datetime import datetime, timedelta
@@ -8,13 +11,13 @@
811
import pytest
912
from numpy.testing import assert_almost_equal, assert_equal
1013

14+
import cftime
1115
from cftime import datetime as datetimex
1216
from cftime import real_datetime
1317
from cftime import (DateFromJulianDay, Datetime360Day, DatetimeAllLeap,
1418
DatetimeGregorian, DatetimeJulian, DatetimeNoLeap,
1519
DatetimeProlepticGregorian, JulianDayFromDate, _parse_date,
1620
date2index, date2num, num2date, utime)
17-
import cftime
1821

1922
# test cftime module for netCDF time <--> python datetime conversions.
2023

@@ -1106,12 +1109,75 @@ def not_comparable_3():
11061109
self.datetime_date1 > self.date2_365_day
11071110

11081111
def not_comparable_4():
1109-
"compare a datetime instance to something other than a datetime"
1112+
"compare a datetime instance to non-datetime"
11101113
self.date1_365_day > 0
11111114

1115+
def not_comparable_5():
1116+
"compare non-datetime to a datetime instance"
1117+
0 < self.date_1_365_day
1118+
11121119
for func in [not_comparable_1, not_comparable_2, not_comparable_3, not_comparable_4]:
11131120
self.assertRaises(TypeError, func)
11141121

1122+
@pytest.mark.skipif(sys.version_info.major != 2,
1123+
reason='python2 specific, non-comparable test')
1124+
def test_richcmp_py2(self):
1125+
class Rich(object):
1126+
"""Dummy class with traditional rich comparison support."""
1127+
def __lt__(self, other):
1128+
raise NotImplementedError('__lt__')
1129+
def __le__(self, other):
1130+
raise NotImplementedError('__le__')
1131+
def __eq__(self, other):
1132+
raise NotImplementedError('__eq__')
1133+
def __ne__(self, other):
1134+
raise NotImplementedError('__ne__')
1135+
def __gt__(self, other):
1136+
raise NotImplementedError('__gt__')
1137+
def __ge__(self, other):
1138+
raise NotImplementedError('__ge__')
1139+
1140+
class CythonRich(object):
1141+
"""Dummy class with spoof cython rich comparison support."""
1142+
def __richcmp__(self, other):
1143+
"""
1144+
This method is never called. However it is introspected
1145+
by the cftime.datetime.__richcmp__ method, which will then
1146+
return NotImplemented, causing Python to call this classes
1147+
__cmp__ method as a back-stop, and hence spoofing the
1148+
cython specific rich comparison behaviour.
1149+
"""
1150+
pass
1151+
def __cmp__(self, other):
1152+
raise NotImplementedError('__richcmp__')
1153+
1154+
class Pass(object):
1155+
"""Dummy class with no rich comparison support whatsoever."""
1156+
pass
1157+
1158+
class Pass___cmp__(object):
1159+
"""Dummy class that delegates all comparisons."""
1160+
def __cmp__(self, other):
1161+
return NotImplemented
1162+
1163+
# Test LHS operand comparison operator processing.
1164+
for op, expected in [(operator.gt, '__lt__'), (operator.ge, '__le__'),
1165+
(operator.eq, '__eq__'), (operator.ne, '__ne__'),
1166+
(operator.lt, '__gt__'), (operator.le, '__ge__')]:
1167+
with self.assertRaisesRegexp(NotImplementedError, expected):
1168+
op(self.date1_365_day, Rich())
1169+
1170+
with self.assertRaisesRegexp(NotImplementedError, '__richcmp__'):
1171+
op(self.date1_365_day, CythonRich())
1172+
1173+
# Test RHS operand comparison operator processing.
1174+
for op in [operator.gt, operator.ge, operator.eq, operator.ne,
1175+
operator.lt, operator.le]:
1176+
with self.assertRaisesRegexp(TypeError, 'cannot compare'):
1177+
op(Pass(), self.date1_365_day)
1178+
1179+
with self.assertRaisesRegexp(TypeError, 'cannot compare'):
1180+
op(Pass___cmp__(), self.date1_365_day)
11151181

11161182

11171183
class issue17TestCase(unittest.TestCase):

0 commit comments

Comments
 (0)