From 4ed5ef23e02d01c0c073459817f8c3920b1b0580 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 12:40:37 -0400 Subject: [PATCH 1/4] Return snake-cased member name from str() on enum values Enum members are exposed to Python with upper-cased snake case names (e.g. FileAccess.READ_WRITE), but str() on an enum value returned the C# member name from Enum.ToString() (e.g. ReadWrite). str() and f-string formatting now return the same Python-facing snake-cased name used to access the member. Flags combinations keep the comma-separated format with each name snake-cased, and values without a defined member keep the raw numeric representation. String equality comparison now accepts the snake-cased name as well, consistently with str(), while still matching the C# member name. --- src/embed_tests/EnumTests.cs | 90 +++++++++++++++++++++++++++++++++ src/runtime/Types/EnumObject.cs | 39 ++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/embed_tests/EnumTests.cs b/src/embed_tests/EnumTests.cs index 8deeea1cd..dbfe837f6 100644 --- a/src/embed_tests/EnumTests.cs +++ b/src/embed_tests/EnumTests.cs @@ -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() { @@ -337,6 +347,59 @@ def operation(): Assert.AreEqual(expectedResult, module.InvokeMethod("operation").As()); } + [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()); + Assert.AreEqual(expectedStr, module.InvokeMethod("get_formatted").As()); + } + + [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()); + + using var pyMinusOne = (-1).ToPython(); + Assert.AreEqual("-1", module.InvokeMethod("get_str", pyMinusOne).As()); + } + [TestCase("==", VerticalDirection.Down, "Down", true)] [TestCase("==", VerticalDirection.Down, "Flat", false)] [TestCase("==", VerticalDirection.Down, "Up", false)] @@ -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(); @@ -375,6 +445,26 @@ def operation2(): Assert.AreEqual(expectedResult, module.InvokeMethod("operation2").As()); } + [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()); + } + public static IEnumerable OtherEnumsComparisonOperatorsTestCases { get diff --git a/src/runtime/Types/EnumObject.cs b/src/runtime/Types/EnumObject.cs index d836a88ad..6941db49c 100644 --- a/src/runtime/Types/EnumObject.cs +++ b/src/runtime/Types/EnumObject.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Runtime.CompilerServices; namespace Python.Runtime @@ -13,6 +14,38 @@ internal EnumObject(Type type) : base(type) { } + /// + /// 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". + /// + 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)); + } + + /// + /// 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. + /// + 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; + } + return string.Join(", ", text.Split(new[] { ", " }, StringSplitOptions.None) + .Select(name => name.ToSnakeCase(constant: true))); + } + /// /// Standard comparison implementation for instances of enum types. /// @@ -115,6 +148,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 { From de2f250078d18f520ba29bb0e97ca0447671a63c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 12:41:27 -0400 Subject: [PATCH 2/4] Update version to 2.0.61 --- src/perf_tests/Python.PerformanceTests.csproj | 4 ++-- src/runtime/Properties/AssemblyInfo.cs | 4 ++-- src/runtime/Python.Runtime.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/perf_tests/Python.PerformanceTests.csproj b/src/perf_tests/Python.PerformanceTests.csproj index feb559f1e..2655a5c10 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/Properties/AssemblyInfo.cs b/src/runtime/Properties/AssemblyInfo.cs index eb24839a9..9514b7ab6 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.60")] -[assembly: AssemblyFileVersion("2.0.60")] +[assembly: AssemblyVersion("2.0.61")] +[assembly: AssemblyFileVersion("2.0.61")] diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index 931d119d1..66f746d08 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.60 + 2.0.61 false LICENSE https://github.com/pythonnet/pythonnet From 679b758d939b386e4873d6bac053e21ded1d5fa1 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 15:32:18 -0400 Subject: [PATCH 3/4] Add fast path for single-name enum values in ToPythonString --- src/runtime/Types/EnumObject.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/runtime/Types/EnumObject.cs b/src/runtime/Types/EnumObject.cs index 6941db49c..417aad0e0 100644 --- a/src/runtime/Types/EnumObject.cs +++ b/src/runtime/Types/EnumObject.cs @@ -42,6 +42,11 @@ internal static string ToPythonString(Enum value) { 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))); } From 48f3aeebebedaa78fd63a23d507da344985c176a Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 10 Jul 2026 15:32:54 -0400 Subject: [PATCH 4/4] Add Lean Python regression tests CI workflow --- .../lean-python-regression-tests.yml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/lean-python-regression-tests.yml diff --git a/.github/workflows/lean-python-regression-tests.yml b/.github/workflows/lean-python-regression-tests.yml new file mode 100644 index 000000000..d88946c7a --- /dev/null +++ b/.github/workflows/lean-python-regression-tests.yml @@ -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\"\)