@@ -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 {
0 commit comments