FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Support subtraction and ordering between converted DateTime values an… · QuantConnect/pythonnet@9fc7ff1 · GitHub

Commit 9fc7ff1

Browse files
authored
Support subtraction and ordering between converted DateTime values and datetime.date (#143)
* Support subtraction and ordering between converted DateTime values and datetime.date * Update version to 2.0.65
1 parent e907acd commit 9fc7ff1

6 files changed

Lines changed: 220 additions & 10 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/perf_tests/Python.PerformanceTests.csproj‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1414
</PackageReference>
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
16-
<PackageReference Include="quantconnect.pythonnet" Version="2.0.64" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.65" GeneratePathProperty="true">
1717
<IncludeAssets>compile</IncludeAssets>
1818
</PackageReference>
1919
</ItemGroup>
@@ -25,7 +25,7 @@
2525
</Target>
2626

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

3131
<Target Name="CopyNewBuild" AfterTargets="Build">

‎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

‎src/runtime/Properties/AssemblyInfo.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
[assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
55
[assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
66

7-
[assembly: AssemblyVersion("2.0.64")]
8-
[assembly: AssemblyFileVersion("2.0.64")]
7+
[assembly: AssemblyVersion("2.0.65")]
8+
[assembly: AssemblyFileVersion("2.0.65")]

‎src/runtime/Python.Runtime.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<RootNamespace>Python.Runtime</RootNamespace>
66
<AssemblyName>Python.Runtime</AssemblyName>
77
<PackageId>QuantConnect.pythonnet</PackageId>
8-
<Version>2.0.64</Version>
8+
<Version>2.0.65</Version>
99
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
1010
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1111
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>

‎tests/test_conversion.py‎

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

546546
ob = ConversionTest()
547-
assert type(ob.DateTimeField) is type(datetime(1,1,1))
547+
# System.DateTime converts to a datetime subclass that also supports
548+
# arithmetic and ordering against pure datetime.date operands
549+
assert isinstance(ob.DateTimeField, datetime)
548550
assert ob.DateTimeField.day == 1
549551

550552
ob.DateTimeField = datetime(2000,1,2)
@@ -558,6 +560,32 @@ def test_datetime_conversion():
558560
with pytest.raises(TypeError):
559561
ConversionTest().DateTimeField = "spam"
560562

563+
def test_datetime_date_coercion():
564+
"""Converted System.DateTime values coerce operations against pure
565+
datetime.date operands using their date part instead of raising TypeError."""
566+
from datetime import date, datetime, timedelta
567+
568+
ob = ConversionTest()
569+
ob.DateTimeField = datetime(2019, 8, 15, 10, 30, 0)
570+
value = ob.DateTimeField
571+
572+
assert (value - date(2019, 7, 1)).days == 45
573+
assert (date(2019, 7, 1) - value).days == -45
574+
assert value > date(2019, 7, 1)
575+
assert date(2019, 7, 1) <= value
576+
assert value <= date(2019, 8, 15)
577+
assert value >= date(2019, 8, 15)
578+
# equality with a pure date stays False (hash contract preserved)
579+
assert not value == date(2019, 8, 15)
580+
581+
# plain datetime behavior is unchanged
582+
assert value == datetime(2019, 8, 15, 10, 30, 0)
583+
assert hash(value) == hash(datetime(2019, 8, 15, 10, 30, 0))
584+
assert value - datetime(2019, 8, 15) == timedelta(hours=10, minutes=30)
585+
assert value + timedelta(days=1) == datetime(2019, 8, 16, 10, 30, 0)
586+
assert repr(value) == repr(datetime(2019, 8, 15, 10, 30))
587+
assert str(value) == str(datetime(2019, 8, 15, 10, 30))
588+
561589
def test_string_conversion():
562590
"""Test string / unicode conversion."""
563591
ob = ConversionTest()

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL