Skip to content
Merged
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
87 changes: 87 additions & 0 deletions .github/workflows/lean-python-regression-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Lean Python Regression Tests

# Validates a Python.Runtime.dll change against Lean's Python regression
# algorithms, mirroring Lean's own .github/workflows/regression-tests.yml.
# We build this repo's Python.Runtime.dll, build Lean against its NuGet
# QuantConnect.pythonnet reference, drop the freshly built DLL over Lean's
# test output, and run only the Python regression tests.

on:
push:
branches:
- master
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lean-python-regression:
runs-on: ubuntu-24.04
timeout-minutes: 360
steps:
- name: Checkout pythonnet
uses: actions/checkout@v4
with:
path: pythonnet

- name: Checkout Lean
uses: actions/checkout@v4
with:
repository: QuantConnect/Lean
path: Lean

- name: Liberate disk space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
large-packages: false
docker-images: false
swap-storage: false

- name: Define docker helper
run: |
echo 'runInContainer() { docker exec test-container "$@"; }' > $HOME/ci_functions.sh
echo "BASH_ENV=$HOME/ci_functions.sh" >> $GITHUB_ENV

- name: Start container
run: |
docker run -d \
--workdir /__w/pythonnet/pythonnet \
-v /home/runner/work:/__w \
--name test-container \
quantconnect/lean:foundation \
tail -f /dev/null

- name: Build Python.Runtime.dll
run: |
runInContainer dotnet build pythonnet/src/runtime/Python.Runtime.csproj \
-c Release /v:quiet /p:WarningLevel=1

- name: Build Lean
run: |
runInContainer dotnet build /p:Configuration=Release /v:quiet /p:WarningLevel=1 \
Lean/QuantConnect.Lean.sln

- name: Inject freshly built Python.Runtime.dll into Lean test output
run: |
runInContainer cp pythonnet/pythonnet/runtime/Python.Runtime.dll \
Lean/Tests/bin/Release/Python.Runtime.dll

- name: Restrict regression tests to Python only
run: |
# Lean's RegressionTests reads the "regression-test-languages" config
# key (defaults to CSharp + Python). Setting it to Python only makes the
# test factory emit Python test cases exclusively. The config file is
# JSONC; Newtonsoft tolerates the inserted line.
runInContainer sed -i '1a\ "regression-test-languages": ["Python"],' \
Lean/Tests/bin/Release/config.json

- name: Run Lean Python regression tests
run: |
runInContainer dotnet test Lean/Tests/bin/Release/QuantConnect.Tests.dll \
--blame-hang-timeout 300seconds --blame-crash \
--filter "TestCategory=RegressionTests & Name~Python/" \
-- TestRunParameters.Parameter\(name=\"log-handler\", value=\"ConsoleErrorLogHandler\"\) \
TestRunParameters.Parameter\(name=\"reduced-disk-size\", value=\"true\"\)
90 changes: 90 additions & 0 deletions src/embed_tests/EnumTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ public enum HorizontalDirection
Right = 2,
}

[Flags]
public enum FileAccessType
{
None = 0,
Read = 1,
Write = 2,
ReadWrite = Read | Write,
Delete = 4,
}

[Test]
public void CSharpEnumsBehaveAsEnumsInPython()
{
Expand Down Expand Up @@ -337,6 +347,59 @@ def operation():
Assert.AreEqual(expectedResult, module.InvokeMethod("operation").As<bool>());
}

[TestCase(nameof(VerticalDirection) + ".DOWN", "DOWN")]
[TestCase(nameof(VerticalDirection) + ".FLAT", "FLAT")]
[TestCase(nameof(VerticalDirection) + ".UP", "UP")]
[TestCase(nameof(VerticalDirection) + ".Down", "DOWN")]
[TestCase(nameof(FileAccessType) + ".NONE", "NONE")]
[TestCase(nameof(FileAccessType) + ".READ", "READ")]
[TestCase(nameof(FileAccessType) + ".READ_WRITE", "READ_WRITE")]
[TestCase(nameof(FileAccessType) + ".ReadWrite", "READ_WRITE")]
[TestCase(nameof(FileAccessType) + ".READ | " + nameof(EnumTests) + "." + nameof(FileAccessType) + ".DELETE", "READ, DELETE")]
public void StrReturnsSnakeCasedMemberName(string valueExpression, string expectedStr)
{
using var _ = Py.GIL();
using var module = PyModule.FromString("StrReturnsSnakeCasedMemberName", $@"
from clr import AddReference
AddReference(""Python.EmbeddingTest"")

from Python.EmbeddingTest import *

enum_value = {nameof(EnumTests)}.{valueExpression}

def get_str():
return str(enum_value)

def get_formatted():
return f'{{enum_value}}'
");

Assert.AreEqual(expectedStr, module.InvokeMethod("get_str").As<string>());
Assert.AreEqual(expectedStr, module.InvokeMethod("get_formatted").As<string>());
}

[Test]
public void StrReturnsNumericRepresentationForUndefinedValues()
{
using var _ = Py.GIL();
using var module = PyModule.FromString("StrReturnsNumericRepresentationForUndefinedValues", $@"
from clr import AddReference
AddReference(""Python.EmbeddingTest"")

from System import Enum
from Python.EmbeddingTest import *

def get_str(int_value):
return str(Enum.ToObject({nameof(EnumTests)}.{nameof(VerticalDirection)}, int_value))
");

using var pyOne = 1.ToPython();
Assert.AreEqual("1", module.InvokeMethod("get_str", pyOne).As<string>());

using var pyMinusOne = (-1).ToPython();
Assert.AreEqual("-1", module.InvokeMethod("get_str", pyMinusOne).As<string>());
}

[TestCase("==", VerticalDirection.Down, "Down", true)]
[TestCase("==", VerticalDirection.Down, "Flat", false)]
[TestCase("==", VerticalDirection.Down, "Up", false)]
Expand All @@ -355,6 +418,13 @@ def operation():
[TestCase("!=", VerticalDirection.Up, "Down", true)]
[TestCase("!=", VerticalDirection.Up, "Flat", true)]
[TestCase("!=", VerticalDirection.Up, "Up", false)]
// The Python-facing snake-cased names are accepted too, consistently with str()
[TestCase("==", VerticalDirection.Down, "DOWN", true)]
[TestCase("==", VerticalDirection.Flat, "FLAT", true)]
[TestCase("==", VerticalDirection.Up, "UP", true)]
[TestCase("==", VerticalDirection.Down, "UP", false)]
[TestCase("!=", VerticalDirection.Down, "DOWN", false)]
[TestCase("!=", VerticalDirection.Down, "UP", true)]
public void EnumComparisonOperatorsWorkWithString(string @operator, VerticalDirection operand1, string operand2, bool expectedResult)
{
using var _ = Py.GIL();
Expand All @@ -375,6 +445,26 @@ def operation2():
Assert.AreEqual(expectedResult, module.InvokeMethod("operation2").As<bool>());
}

[TestCase("ReadWrite", true)]
[TestCase("READ_WRITE", true)]
[TestCase("READWRITE", false)]
[TestCase("read_write", false)]
public void MultiWordEnumMembersCompareWithBothCSharpAndSnakeCasedNames(string operand, bool expectedResult)
{
using var _ = Py.GIL();
using var module = PyModule.FromString("MultiWordEnumMembersCompareWithBothCSharpAndSnakeCasedNames", $@"
from clr import AddReference
AddReference(""Python.EmbeddingTest"")

from Python.EmbeddingTest import *

def operation():
return {nameof(EnumTests)}.{nameof(FileAccessType)}.READ_WRITE == ""{operand}""
");

Assert.AreEqual(expectedResult, module.InvokeMethod("operation").As<bool>());
}

public static IEnumerable<TestCaseData> OtherEnumsComparisonOperatorsTestCases
{
get
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.60" GeneratePathProperty="true">
<PackageReference Include="quantconnect.pythonnet" Version="2.0.61" 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.60\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.61\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
</Target>

<Target Name="CopyNewBuild" AfterTargets="Build">
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.60")]
[assembly: AssemblyFileVersion("2.0.60")]
[assembly: AssemblyVersion("2.0.61")]
[assembly: AssemblyFileVersion("2.0.61")]
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.60</Version>
<Version>2.0.61</Version>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>
Expand Down
44 changes: 44 additions & 0 deletions src/runtime/Types/EnumObject.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Runtime.CompilerServices;

namespace Python.Runtime
Expand All @@ -13,6 +14,43 @@ internal EnumObject(Type type) : base(type)
{
}

/// <summary>
/// Standard __str__ implementation for instances of enum types.
/// Returns the Python-facing member name, that is, the same upper-cased snake case
/// used to access the member from Python, so that str(MyEnum.MY_VALUE) == "MY_VALUE".
/// </summary>
public static NewReference tp_str(BorrowedReference ob)
{
if (GetManagedObject(ob) is not CLRObject co || co.inst is not Enum inst)
{
return Exceptions.RaiseTypeError("invalid object");
}
return Runtime.PyString_FromString(ToPythonString(inst));
}

/// <summary>
/// Gets the string representation of the given enum value as exposed to Python:
/// the upper-cased snake case name of the member (e.g. "READ_WRITE" for FileAccess.ReadWrite).
/// Flags combinations keep Enum.ToString()'s comma-separated format with each name snake-cased,
/// and values without a defined name keep the raw numeric representation.
/// </summary>
internal static string ToPythonString(Enum value)
{
var text = value.ToString();
// Enum.ToString() yields the numeric value when it doesn't map to any defined member
if (text.Length == 0 || char.IsDigit(text[0]) || text[0] == '-')
{
return text;
}
// Fast path: a single member name (the common case), not a comma-separated flags combination
if (text.IndexOf(',') < 0)
{
return text.ToSnakeCase(constant: true);
}
return string.Join(", ", text.Split(new[] { ", " }, StringSplitOptions.None)
.Select(name => name.ToSnakeCase(constant: true)));
}

/// <summary>
/// Standard comparison implementation for instances of enum types.
/// </summary>
Expand Down Expand Up @@ -115,6 +153,12 @@ private static bool TryCompare(Enum left, object right, out int result)
else if (right is string rightString)
{
result = left.ToString().CompareTo(rightString);
if (result != 0 && ToPythonString(left) == rightString)
{
// also match the Python-facing snake-cased name, e.g. FileAccess.READ_WRITE == "READ_WRITE",
// so string comparison is consistent with str() on the enum value
result = 0;
}
}
else
{
Expand Down
Loading