Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/embed_tests/TestConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>());

using var reverseDte = module.InvokeMethod("reverse_dte", pyExpiry);
Assert.AreEqual(-45, reverseDte.As<int>());
}
}

[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<bool[]>();
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<bool[]>();
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<bool[]>();
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<bool[]>();
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()
{
Expand Down
4 changes: 2 additions & 2 deletions src/perf_tests/Python.PerformanceTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
<PackageReference Include="quantconnect.pythonnet" Version="2.0.64" GeneratePathProperty="true">
<PackageReference Include="quantconnect.pythonnet" Version="2.0.65" GeneratePathProperty="true">
<IncludeAssets>compile</IncludeAssets>
</PackageReference>
</ItemGroup>
Expand All @@ -25,7 +25,7 @@
</Target>

<Target Name="CopyBaseline" AfterTargets="Build">
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.64\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.65\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
</Target>

<Target Name="CopyNewBuild" AfterTargets="Build">
Expand Down
62 changes: 58 additions & 4 deletions src/runtime/Converter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyObject> dateTimeCtor;
private static PyObject timeSpanCtor;
private static Lazy<PyObject> tzInfoCtor;
private static PyObject pyTupleNoKind;
Expand Down Expand Up @@ -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<PyObject>(() =>
{
// 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);
Expand Down Expand Up @@ -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;


Expand Down
4 changes: 2 additions & 2 deletions src/runtime/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
2 changes: 1 addition & 1 deletion src/runtime/Python.Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<RootNamespace>Python.Runtime</RootNamespace>
<AssemblyName>Python.Runtime</AssemblyName>
<PackageId>QuantConnect.pythonnet</PackageId>
<Version>2.0.64</Version>
<Version>2.0.65</Version>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>
Expand Down
30 changes: 29 additions & 1 deletion tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Loading