diff --git a/src/embed_tests/TestConverter.cs b/src/embed_tests/TestConverter.cs index 3f711f62c..98aa43280 100644 --- a/src/embed_tests/TestConverter.cs +++ b/src/embed_tests/TestConverter.cs @@ -279,6 +279,134 @@ public void ConvertDateTimeWithExplicitUTCTimeZonePythonToCSharp() } } + // The datetime instances produced for System.DateTime values coerce operations + // against pure datetime.date operands using their date part instead of raising + // TypeError, while behaving exactly like plain datetimes everywhere else. + private static PyModule GetDateTimeCoercionModule() + { + return PyModule.FromString("datetime_coercion_test", @" +from datetime import date, datetime, timedelta +import pickle + +TODAY = date(2019, 7, 1) + +def dte(dt): + return (dt - TODAY).days + +def reverse_dte(dt): + return (TODAY - dt).days + +def compare_with_dates(dt): + earlier = date(2019, 7, 1) + later = date(2019, 12, 31) + return [earlier < dt, earlier <= dt, dt > earlier, dt >= earlier, + dt < later, dt <= later, later > dt, later >= dt] + +def same_day_comparisons(dt): + same = date(dt.year, dt.month, dt.day) + return [dt <= same, dt >= same, dt < same, dt > same, dt == same] + +def datetime_behavior_unchanged(dt): + plain = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond) + shifted = dt + timedelta(days=1) + return [isinstance(dt, datetime), dt == plain, hash(dt) == hash(plain), + dt - plain == timedelta(0), shifted - dt == timedelta(days=1), + dt < shifted, str(dt) == str(plain), repr(dt) == repr(plain), + dt.strftime('%Y-%m-%d %H:%M') == plain.strftime('%Y-%m-%d %H:%M')] + +def pickle_as_plain_datetime(dt): + restored = pickle.loads(pickle.dumps(dt)) + plain = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond) + return [type(restored) is datetime, restored == plain] +"); + } + + [Test] + public void ConvertedDateTimeSubtractionWithPureDateUsesDatePart() + { + using (Py.GIL()) + { + using var module = GetDateTimeCoercionModule(); + // e.g. contract expiry minus the user's date.today() + using var pyExpiry = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject(); + + using var dte = module.InvokeMethod("dte", pyExpiry); + Assert.AreEqual(45, dte.As()); + + using var reverseDte = module.InvokeMethod("reverse_dte", pyExpiry); + Assert.AreEqual(-45, reverseDte.As()); + } + } + + [Test] + public void ConvertedDateTimeComparisonWithPureDateUsesDatePart() + { + using (Py.GIL()) + { + using var module = GetDateTimeCoercionModule(); + using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject(); + + using var comparisons = module.InvokeMethod("compare_with_dates", pyDateTime); + var results = comparisons.As(); + for (var i = 0; i < results.Length; i++) + { + Assert.IsTrue(results[i], $"comparison {i} was false"); + } + } + } + + [Test] + public void ConvertedDateTimeSameDayComparisonWithPureDate() + { + using (Py.GIL()) + { + using var module = GetDateTimeCoercionModule(); + using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject(); + + using var comparisons = module.InvokeMethod("same_day_comparisons", pyDateTime); + var results = comparisons.As(); + Assert.IsTrue(results[0], "dt <= same-day date"); + Assert.IsTrue(results[1], "dt >= same-day date"); + Assert.IsFalse(results[2], "dt < same-day date"); + Assert.IsFalse(results[3], "dt > same-day date"); + // equality with a pure date stays False: making it true would break the + // hash contract since hash(datetime) != hash(date) + Assert.IsFalse(results[4], "dt == same-day date"); + } + } + + [Test] + public void ConvertedDateTimeBehavesLikePlainDateTime() + { + using (Py.GIL()) + { + using var module = GetDateTimeCoercionModule(); + using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0, 5)).MoveToPyObject(); + + using var checks = module.InvokeMethod("datetime_behavior_unchanged", pyDateTime); + var results = checks.As(); + for (var i = 0; i < results.Length; i++) + { + Assert.IsTrue(results[i], $"behavior check {i} failed"); + } + } + } + + [Test] + public void ConvertedDateTimePicklesAsPlainDateTime() + { + using (Py.GIL()) + { + using var module = GetDateTimeCoercionModule(); + using var pyDateTime = Converter.ToPython(new DateTime(2019, 8, 15, 10, 30, 0)).MoveToPyObject(); + + using var checks = module.InvokeMethod("pickle_as_plain_datetime", pyDateTime); + var results = checks.As(); + Assert.IsTrue(results[0], "unpickled type should be plain datetime.datetime"); + Assert.IsTrue(results[1], "unpickled value should equal the original"); + } + } + [Test] public void ConvertTimestampRoundTrip() { diff --git a/src/perf_tests/Python.PerformanceTests.csproj b/src/perf_tests/Python.PerformanceTests.csproj index e72948e95..2c18d49cd 100644 --- a/src/perf_tests/Python.PerformanceTests.csproj +++ b/src/perf_tests/Python.PerformanceTests.csproj @@ -13,7 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + compile @@ -25,7 +25,7 @@ - + diff --git a/src/runtime/Converter.cs b/src/runtime/Converter.cs index 51dbed7fe..46511d510 100644 --- a/src/runtime/Converter.cs +++ b/src/runtime/Converter.cs @@ -58,7 +58,7 @@ internal static void Reset() private static Type flagsType; private static Type boolType; private static Type typeType; - private static PyObject dateTimeCtor; + private static Lazy dateTimeCtor; private static PyObject timeSpanCtor; private static Lazy tzInfoCtor; private static PyObject pyTupleNoKind; @@ -94,8 +94,62 @@ static Converter() var dateTimeMod = Runtime.PyImport_ImportModule("datetime"); PythonException.ThrowIfIsNull(dateTimeMod); - dateTimeCtor = Runtime.PyObject_GetAttrString(dateTimeMod.Borrow(), "datetime").MoveToPyObject(); - PythonException.ThrowIfIsNull(dateTimeCtor); + dateTimeCtor = new Lazy(() => + { + // datetime.datetime subclass whose subtraction and ordering against pure + // datetime.date operands coerce to the date part instead of raising TypeError. + // Equality and hashing are left untouched: making a datetime equal a date + // would break the hash contract. Pickling degrades to the plain datetime + // class so payloads never reference this synthetic module. + var dateTimeSubclassMod = PyModule.FromString("clr_datetime", @" +from datetime import datetime as _datetime, date as _date + +class datetime(_datetime): + __slots__ = () + + def __sub__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return self.date() - other + return _datetime.__sub__(self, other) + + def __rsub__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return other - self.date() + return _datetime.__rsub__(self, other) + + def __lt__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return self.date() < other + return _datetime.__lt__(self, other) + + def __le__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return self.date() <= other + return _datetime.__le__(self, other) + + def __gt__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return self.date() > other + return _datetime.__gt__(self, other) + + def __ge__(self, other): + if isinstance(other, _date) and not isinstance(other, _datetime): + return self.date() >= other + return _datetime.__ge__(self, other) + + def __repr__(self): + base = _datetime.__repr__(self) + return 'datetime.datetime' + base[base.index('('):] + + def __reduce_ex__(self, protocol): + return (_datetime, (self.year, self.month, self.day, self.hour, self.minute, + self.second, self.microsecond, self.tzinfo)) +").BorrowNullable(); + + var result = Runtime.PyObject_GetAttrString(dateTimeSubclassMod, "datetime").MoveToPyObject(); + PythonException.ThrowIfIsNull(result); + return result; + }); timeSpanCtor = Runtime.PyObject_GetAttrString(dateTimeMod.Borrow(), "timedelta").MoveToPyObject(); PythonException.ThrowIfIsNull(timeSpanCtor); @@ -375,7 +429,7 @@ internal static NewReference ToPython(object? value, Type type) Runtime.PyTuple_SetItem(dateTimeArgs, 7, TzInfo(datetime.Kind).Steal()); } - var returnDateTime = Runtime.PyObject_CallObject(dateTimeCtor, dateTimeArgs); + var returnDateTime = Runtime.PyObject_CallObject(dateTimeCtor.Value, dateTimeArgs); return returnDateTime; diff --git a/src/runtime/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index 3700bd52c..875a1286d 100644 --- a/src/runtime/Properties/AssemblyInfo.cs +++ b/src/runtime/Properties/AssemblyInfo.cs @@ -4,5 +4,5 @@ [assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] [assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")] -[assembly: AssemblyVersion("2.0.64")] -[assembly: AssemblyFileVersion("2.0.64")] +[assembly: AssemblyVersion("2.0.65")] +[assembly: AssemblyFileVersion("2.0.65")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index cf86a3f28..d8e720ef4 100644 --- a/src/runtime/Python.Runtime.csproj +++ b/src/runtime/Python.Runtime.csproj @@ -5,7 +5,7 @@ Python.Runtime Python.Runtime QuantConnect.pythonnet - 2.0.64 + 2.0.65 false LICENSE https://github.com/pythonnet/pythonnet diff --git a/tests/test_conversion.py b/tests/test_conversion.py index ae2b0f18a..824d6f969 100644 --- a/tests/test_conversion.py +++ b/tests/test_conversion.py @@ -493,7 +493,9 @@ def test_datetime_conversion(): from datetime import datetime ob = ConversionTest() - assert type(ob.DateTimeField) is type(datetime(1,1,1)) + # System.DateTime converts to a datetime subclass that also supports + # arithmetic and ordering against pure datetime.date operands + assert isinstance(ob.DateTimeField, datetime) assert ob.DateTimeField.day == 1 ob.DateTimeField = datetime(2000,1,2) @@ -507,6 +509,32 @@ def test_datetime_conversion(): with pytest.raises(TypeError): ConversionTest().DateTimeField = "spam" +def test_datetime_date_coercion(): + """Converted System.DateTime values coerce operations against pure + datetime.date operands using their date part instead of raising TypeError.""" + from datetime import date, datetime, timedelta + + ob = ConversionTest() + ob.DateTimeField = datetime(2019, 8, 15, 10, 30, 0) + value = ob.DateTimeField + + assert (value - date(2019, 7, 1)).days == 45 + assert (date(2019, 7, 1) - value).days == -45 + assert value > date(2019, 7, 1) + assert date(2019, 7, 1) <= value + assert value <= date(2019, 8, 15) + assert value >= date(2019, 8, 15) + # equality with a pure date stays False (hash contract preserved) + assert not value == date(2019, 8, 15) + + # plain datetime behavior is unchanged + assert value == datetime(2019, 8, 15, 10, 30, 0) + assert hash(value) == hash(datetime(2019, 8, 15, 10, 30, 0)) + assert value - datetime(2019, 8, 15) == timedelta(hours=10, minutes=30) + assert value + timedelta(days=1) == datetime(2019, 8, 16, 10, 30, 0) + assert repr(value) == repr(datetime(2019, 8, 15, 10, 30)) + assert str(value) == str(datetime(2019, 8, 15, 10, 30)) + def test_string_conversion(): """Test string / unicode conversion.""" ob = ConversionTest()