Skip to content

Commit 5aeb934

Browse files
committed
Support subtraction and ordering between converted DateTime values and datetime.date
1 parent c080337 commit 5aeb934

3 files changed

Lines changed: 215 additions & 5 deletions

File tree

src/embed_tests/TestConverter.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,134 @@ public void ConvertDateTimeWithExplicitUTCTimeZonePythonToCSharp()
279279
}
280280
}
281281

282+
// The datetime instances produced for System.DateTime values coerce operations
283+
// against pure datetime.date operands using their date part instead of raising
284+
// TypeError, while behaving exactly like plain datetimes everywhere else.
285+
private static PyModule GetDateTimeCoercionModule()
286+
{
287+
return PyModule.FromString("datetime_coercion_test", @"
288+
from datetime import date, datetime, timedelta
289+
import pickle
290+
291+
TODAY = date(2019, 7, 1)
292+
293+
def dte(dt):
294+
return (dt - TODAY).days
295+
296+
def reverse_dte(dt):
297+
return (TODAY - dt).days
298+
299+
def compare_with_dates(dt):
300+
earlier = date(2019, 7, 1)
301+
later = date(2019, 12, 31)
302+
return [earlier < dt, earlier <= dt, dt > earlier, dt >= earlier,
303+
dt < later, dt <= later, later > dt, later >= dt]
304+
305+
def same_day_comparisons(dt):
306+
same = date(dt.year, dt.month, dt.day)
307+
return [dt <= same, dt >= same, dt < same, dt > same, dt == same]
308+
309+
def datetime_behavior_unchanged(dt):
310+
plain = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond)
311+
shifted = dt + timedelta(days=1)
312+
return [isinstance(dt, datetime), dt == plain, hash(dt) == hash(plain),
313+
dt - plain == timedelta(0), shifted - dt == timedelta(days=1),
314+
dt < shifted, str(dt) == str(plain), repr(dt) == repr(plain),
315+
dt.strftime('%Y-%m-%d %H:%M') == plain.strftime('%Y-%m-%d %H:%M')]
316+
317+
def pickle_as_plain_datetime(dt):
318+
restored = pickle.loads(pickle.dumps(dt))
319+
plain = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond)
320+
return [type(restored) is datetime, restored == plain]
321+
");
322+
}
323+
324+
[Test]
325+
public void ConvertedDateTimeSubtractionWithPureDateUsesDatePart()
326+
{
327+
using (Py.GIL())
328+
{
329+
using var module = GetDateTimeCoercionModule();
330+
// e.g. contract expiry minus the user's date.today()
331+
using var pyExpiry = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject();
332+
333+
using var dte = module.InvokeMethod("dte", pyExpiry);
334+
Assert.AreEqual(45, dte.As<int>());
335+
336+
using var reverseDte = module.InvokeMethod("reverse_dte", pyExpiry);
337+
Assert.AreEqual(-45, reverseDte.As<int>());
338+
}
339+
}
340+
341+
[Test]
342+
public void ConvertedDateTimeComparisonWithPureDateUsesDatePart()
343+
{
344+
using (Py.GIL())
345+
{
346+
using var module = GetDateTimeCoercionModule();
347+
using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject();
348+
349+
using var comparisons = module.InvokeMethod("compare_with_dates", pyDateTime);
350+
var results = comparisons.As<bool[]>();
351+
for (var i = 0; i < results.Length; i++)
352+
{
353+
Assert.IsTrue(results[i], $"comparison {i} was false");
354+
}
355+
}
356+
}
357+
358+
[Test]
359+
public void ConvertedDateTimeSameDayComparisonWithPureDate()
360+
{
361+
using (Py.GIL())
362+
{
363+
using var module = GetDateTimeCoercionModule();
364+
using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject();
365+
366+
using var comparisons = module.InvokeMethod("same_day_comparisons", pyDateTime);
367+
var results = comparisons.As<bool[]>();
368+
Assert.IsTrue(results[0], "dt <= same-day date");
369+
Assert.IsTrue(results[1], "dt >= same-day date");
370+
Assert.IsFalse(results[2], "dt < same-day date");
371+
Assert.IsFalse(results[3], "dt > same-day date");
372+
// equality with a pure date stays False: making it true would break the
373+
// hash contract since hash(datetime) != hash(date)
374+
Assert.IsFalse(results[4], "dt == same-day date");
375+
}
376+
}
377+
378+
[Test]
379+
public void ConvertedDateTimeBehavesLikePlainDateTime()
380+
{
381+
using (Py.GIL())
382+
{
383+
using var module = GetDateTimeCoercionModule();
384+
using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0, 5)).MoveToPyObject();
385+
386+
using var checks = module.InvokeMethod("datetime_behavior_unchanged", pyDateTime);
387+
var results = checks.As<bool[]>();
388+
for (var i = 0; i < results.Length; i++)
389+
{
390+
Assert.IsTrue(results[i], $"behavior check {i} failed");
391+
}
392+
}
393+
}
394+
395+
[Test]
396+
public void ConvertedDateTimePicklesAsPlainDateTime()
397+
{
398+
using (Py.GIL())
399+
{
400+
using var module = GetDateTimeCoercionModule();
401+
using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject();
402+
403+
using var checks = module.InvokeMethod("pickle_as_plain_datetime", pyDateTime);
404+
var results = checks.As<bool[]>();
405+
Assert.IsTrue(results[0], "unpickled type should be plain datetime.datetime");
406+
Assert.IsTrue(results[1], "unpickled value should equal the original");
407+
}
408+
}
409+
282410
[Test]
283411
public void ConvertTimestampRoundTrip()
284412
{

src/runtime/Converter.cs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ internal static void Reset()
5858
private static Type flagsType;
5959
private static Type boolType;
6060
private static Type typeType;
61-
private static PyObject dateTimeCtor;
61+
private static Lazy<PyObject> dateTimeCtor;
6262
private static PyObject timeSpanCtor;
6363
private static Lazy<PyObject> tzInfoCtor;
6464
private static PyObject pyTupleNoKind;
@@ -94,8 +94,62 @@ static Converter()
9494
var dateTimeMod = Runtime.PyImport_ImportModule("datetime");
9595
PythonException.ThrowIfIsNull(dateTimeMod);
9696

97-
dateTimeCtor = Runtime.PyObject_GetAttrString(dateTimeMod.Borrow(), "datetime").MoveToPyObject();
98-
PythonException.ThrowIfIsNull(dateTimeCtor);
97+
dateTimeCtor = new Lazy<PyObject>(() =>
98+
{
99+
// datetime.datetime subclass whose subtraction and ordering against pure
100+
// datetime.date operands coerce to the date part instead of raising TypeError.
101+
// Equality and hashing are left untouched: making a datetime equal a date
102+
// would break the hash contract. Pickling degrades to the plain datetime
103+
// class so payloads never reference this synthetic module.
104+
var dateTimeSubclassMod = PyModule.FromString("clr_datetime", @"
105+
from datetime import datetime as _datetime, date as _date
106+
107+
class datetime(_datetime):
108+
__slots__ = ()
109+
110+
def __sub__(self, other):
111+
if isinstance(other, _date) and not isinstance(other, _datetime):
112+
return self.date() - other
113+
return _datetime.__sub__(self, other)
114+
115+
def __rsub__(self, other):
116+
if isinstance(other, _date) and not isinstance(other, _datetime):
117+
return other - self.date()
118+
return _datetime.__rsub__(self, other)
119+
120+
def __lt__(self, other):
121+
if isinstance(other, _date) and not isinstance(other, _datetime):
122+
return self.date() < other
123+
return _datetime.__lt__(self, other)
124+
125+
def __le__(self, other):
126+
if isinstance(other, _date) and not isinstance(other, _datetime):
127+
return self.date() <= other
128+
return _datetime.__le__(self, other)
129+
130+
def __gt__(self, other):
131+
if isinstance(other, _date) and not isinstance(other, _datetime):
132+
return self.date() > other
133+
return _datetime.__gt__(self, other)
134+
135+
def __ge__(self, other):
136+
if isinstance(other, _date) and not isinstance(other, _datetime):
137+
return self.date() >= other
138+
return _datetime.__ge__(self, other)
139+
140+
def __repr__(self):
141+
base = _datetime.__repr__(self)
142+
return 'datetime.datetime' + base[base.index('('):]
143+
144+
def __reduce_ex__(self, protocol):
145+
return (_datetime, (self.year, self.month, self.day, self.hour, self.minute,
146+
self.second, self.microsecond, self.tzinfo))
147+
").BorrowNullable();
148+
149+
var result = Runtime.PyObject_GetAttrString(dateTimeSubclassMod, "datetime").MoveToPyObject();
150+
PythonException.ThrowIfIsNull(result);
151+
return result;
152+
});
99153

100154
timeSpanCtor = Runtime.PyObject_GetAttrString(dateTimeMod.Borrow(), "timedelta").MoveToPyObject();
101155
PythonException.ThrowIfIsNull(timeSpanCtor);
@@ -375,7 +429,7 @@ internal static NewReference ToPython(object? value, Type type)
375429
Runtime.PyTuple_SetItem(dateTimeArgs, 7, TzInfo(datetime.Kind).Steal());
376430
}
377431

378-
var returnDateTime = Runtime.PyObject_CallObject(dateTimeCtor, dateTimeArgs);
432+
var returnDateTime = Runtime.PyObject_CallObject(dateTimeCtor.Value, dateTimeArgs);
379433
return returnDateTime;
380434

381435

tests/test_conversion.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,9 @@ def test_datetime_conversion():
493493
from datetime import datetime
494494

495495
ob = ConversionTest()
496-
assert type(ob.DateTimeField) is type(datetime(1,1,1))
496+
# System.DateTime converts to a datetime subclass that also supports
497+
# arithmetic and ordering against pure datetime.date operands
498+
assert isinstance(ob.DateTimeField, datetime)
497499
assert ob.DateTimeField.day == 1
498500

499501
ob.DateTimeField = datetime(2000,1,2)
@@ -507,6 +509,32 @@ def test_datetime_conversion():
507509
with pytest.raises(TypeError):
508510
ConversionTest().DateTimeField = "spam"
509511

512+
def test_datetime_date_coercion():
513+
"""Converted System.DateTime values coerce operations against pure
514+
datetime.date operands using their date part instead of raising TypeError."""
515+
from datetime import date, datetime, timedelta
516+
517+
ob = ConversionTest()
518+
ob.DateTimeField = datetime(2019, 8, 15, 10, 30, 0)
519+
value = ob.DateTimeField
520+
521+
assert (value - date(2019, 7, 1)).days == 45
522+
assert (date(2019, 7, 1) - value).days == -45
523+
assert value > date(2019, 7, 1)
524+
assert date(2019, 7, 1) <= value
525+
assert value <= date(2019, 8, 15)
526+
assert value >= date(2019, 8, 15)
527+
# equality with a pure date stays False (hash contract preserved)
528+
assert not value == date(2019, 8, 15)
529+
530+
# plain datetime behavior is unchanged
531+
assert value == datetime(2019, 8, 15, 10, 30, 0)
532+
assert hash(value) == hash(datetime(2019, 8, 15, 10, 30, 0))
533+
assert value - datetime(2019, 8, 15) == timedelta(hours=10, minutes=30)
534+
assert value + timedelta(days=1) == datetime(2019, 8, 16, 10, 30, 0)
535+
assert repr(value) == repr(datetime(2019, 8, 15, 10, 30))
536+
assert str(value) == str(datetime(2019, 8, 15, 10, 30))
537+
510538
def test_string_conversion():
511539
"""Test string / unicode conversion."""
512540
ob = ConversionTest()

0 commit comments

Comments
 (0)