Skip to content

Commit 687791d

Browse files
authored
Return snake-cased member name from str() on enum values (#135)
* 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. * Update version to 2.0.61 * Add fast path for single-name enum values in ToPythonString * Add Lean Python regression tests CI workflow
1 parent 2b8772c commit 687791d

6 files changed

Lines changed: 226 additions & 5 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Lean Python Regression Tests
2+
3+
# Validates a Python.Runtime.dll change against Lean's Python regression
4+
# algorithms, mirroring Lean's own .github/workflows/regression-tests.yml.
5+
# We build this repo's Python.Runtime.dll, build Lean against its NuGet
6+
# QuantConnect.pythonnet reference, drop the freshly built DLL over Lean's
7+
# test output, and run only the Python regression tests.
8+
9+
on:
10+
push:
11+
branches:
12+
- master
13+
pull_request:
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
lean-python-regression:
21+
runs-on: ubuntu-24.04
22+
timeout-minutes: 360
23+
steps:
24+
- name: Checkout pythonnet
25+
uses: actions/checkout@v4
26+
with:
27+
path: pythonnet
28+
29+
- name: Checkout Lean
30+
uses: actions/checkout@v4
31+
with:
32+
repository: QuantConnect/Lean
33+
path: Lean
34+
35+
- name: Liberate disk space
36+
uses: jlumbroso/free-disk-space@main
37+
with:
38+
tool-cache: true
39+
large-packages: false
40+
docker-images: false
41+
swap-storage: false
42+
43+
- name: Define docker helper
44+
run: |
45+
echo 'runInContainer() { docker exec test-container "$@"; }' > $HOME/ci_functions.sh
46+
echo "BASH_ENV=$HOME/ci_functions.sh" >> $GITHUB_ENV
47+
48+
- name: Start container
49+
run: |
50+
docker run -d \
51+
--workdir /__w/pythonnet/pythonnet \
52+
-v /home/runner/work:/__w \
53+
--name test-container \
54+
quantconnect/lean:foundation \
55+
tail -f /dev/null
56+
57+
- name: Build Python.Runtime.dll
58+
run: |
59+
runInContainer dotnet build pythonnet/src/runtime/Python.Runtime.csproj \
60+
-c Release /v:quiet /p:WarningLevel=1
61+
62+
- name: Build Lean
63+
run: |
64+
runInContainer dotnet build /p:Configuration=Release /v:quiet /p:WarningLevel=1 \
65+
Lean/QuantConnect.Lean.sln
66+
67+
- name: Inject freshly built Python.Runtime.dll into Lean test output
68+
run: |
69+
runInContainer cp pythonnet/pythonnet/runtime/Python.Runtime.dll \
70+
Lean/Tests/bin/Release/Python.Runtime.dll
71+
72+
- name: Restrict regression tests to Python only
73+
run: |
74+
# Lean's RegressionTests reads the "regression-test-languages" config
75+
# key (defaults to CSharp + Python). Setting it to Python only makes the
76+
# test factory emit Python test cases exclusively. The config file is
77+
# JSONC; Newtonsoft tolerates the inserted line.
78+
runInContainer sed -i '1a\ "regression-test-languages": ["Python"],' \
79+
Lean/Tests/bin/Release/config.json
80+
81+
- name: Run Lean Python regression tests
82+
run: |
83+
runInContainer dotnet test Lean/Tests/bin/Release/QuantConnect.Tests.dll \
84+
--blame-hang-timeout 300seconds --blame-crash \
85+
--filter "TestCategory=RegressionTests & Name~Python/" \
86+
-- TestRunParameters.Parameter\(name=\"log-handler\", value=\"ConsoleErrorLogHandler\"\) \
87+
TestRunParameters.Parameter\(name=\"reduced-disk-size\", value=\"true\"\)

src/embed_tests/EnumTests.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ public enum HorizontalDirection
3838
Right = 2,
3939
}
4040

41+
[Flags]
42+
public enum FileAccessType
43+
{
44+
None = 0,
45+
Read = 1,
46+
Write = 2,
47+
ReadWrite = Read | Write,
48+
Delete = 4,
49+
}
50+
4151
[Test]
4252
public void CSharpEnumsBehaveAsEnumsInPython()
4353
{
@@ -337,6 +347,59 @@ def operation():
337347
Assert.AreEqual(expectedResult, module.InvokeMethod("operation").As<bool>());
338348
}
339349

350+
[TestCase(nameof(VerticalDirection) + ".DOWN", "DOWN")]
351+
[TestCase(nameof(VerticalDirection) + ".FLAT", "FLAT")]
352+
[TestCase(nameof(VerticalDirection) + ".UP", "UP")]
353+
[TestCase(nameof(VerticalDirection) + ".Down", "DOWN")]
354+
[TestCase(nameof(FileAccessType) + ".NONE", "NONE")]
355+
[TestCase(nameof(FileAccessType) + ".READ", "READ")]
356+
[TestCase(nameof(FileAccessType) + ".READ_WRITE", "READ_WRITE")]
357+
[TestCase(nameof(FileAccessType) + ".ReadWrite", "READ_WRITE")]
358+
[TestCase(nameof(FileAccessType) + ".READ | " + nameof(EnumTests) + "." + nameof(FileAccessType) + ".DELETE", "READ, DELETE")]
359+
public void StrReturnsSnakeCasedMemberName(string valueExpression, string expectedStr)
360+
{
361+
using var _ = Py.GIL();
362+
using var module = PyModule.FromString("StrReturnsSnakeCasedMemberName", $@"
363+
from clr import AddReference
364+
AddReference(""Python.EmbeddingTest"")
365+
366+
from Python.EmbeddingTest import *
367+
368+
enum_value = {nameof(EnumTests)}.{valueExpression}
369+
370+
def get_str():
371+
return str(enum_value)
372+
373+
def get_formatted():
374+
return f'{{enum_value}}'
375+
");
376+
377+
Assert.AreEqual(expectedStr, module.InvokeMethod("get_str").As<string>());
378+
Assert.AreEqual(expectedStr, module.InvokeMethod("get_formatted").As<string>());
379+
}
380+
381+
[Test]
382+
public void StrReturnsNumericRepresentationForUndefinedValues()
383+
{
384+
using var _ = Py.GIL();
385+
using var module = PyModule.FromString("StrReturnsNumericRepresentationForUndefinedValues", $@"
386+
from clr import AddReference
387+
AddReference(""Python.EmbeddingTest"")
388+
389+
from System import Enum
390+
from Python.EmbeddingTest import *
391+
392+
def get_str(int_value):
393+
return str(Enum.ToObject({nameof(EnumTests)}.{nameof(VerticalDirection)}, int_value))
394+
");
395+
396+
using var pyOne = 1.ToPython();
397+
Assert.AreEqual("1", module.InvokeMethod("get_str", pyOne).As<string>());
398+
399+
using var pyMinusOne = (-1).ToPython();
400+
Assert.AreEqual("-1", module.InvokeMethod("get_str", pyMinusOne).As<string>());
401+
}
402+
340403
[TestCase("==", VerticalDirection.Down, "Down", true)]
341404
[TestCase("==", VerticalDirection.Down, "Flat", false)]
342405
[TestCase("==", VerticalDirection.Down, "Up", false)]
@@ -355,6 +418,13 @@ def operation():
355418
[TestCase("!=", VerticalDirection.Up, "Down", true)]
356419
[TestCase("!=", VerticalDirection.Up, "Flat", true)]
357420
[TestCase("!=", VerticalDirection.Up, "Up", false)]
421+
// The Python-facing snake-cased names are accepted too, consistently with str()
422+
[TestCase("==", VerticalDirection.Down, "DOWN", true)]
423+
[TestCase("==", VerticalDirection.Flat, "FLAT", true)]
424+
[TestCase("==", VerticalDirection.Up, "UP", true)]
425+
[TestCase("==", VerticalDirection.Down, "UP", false)]
426+
[TestCase("!=", VerticalDirection.Down, "DOWN", false)]
427+
[TestCase("!=", VerticalDirection.Down, "UP", true)]
358428
public void EnumComparisonOperatorsWorkWithString(string @operator, VerticalDirection operand1, string operand2, bool expectedResult)
359429
{
360430
using var _ = Py.GIL();
@@ -375,6 +445,26 @@ def operation2():
375445
Assert.AreEqual(expectedResult, module.InvokeMethod("operation2").As<bool>());
376446
}
377447

448+
[TestCase("ReadWrite", true)]
449+
[TestCase("READ_WRITE", true)]
450+
[TestCase("READWRITE", false)]
451+
[TestCase("read_write", false)]
452+
public void MultiWordEnumMembersCompareWithBothCSharpAndSnakeCasedNames(string operand, bool expectedResult)
453+
{
454+
using var _ = Py.GIL();
455+
using var module = PyModule.FromString("MultiWordEnumMembersCompareWithBothCSharpAndSnakeCasedNames", $@"
456+
from clr import AddReference
457+
AddReference(""Python.EmbeddingTest"")
458+
459+
from Python.EmbeddingTest import *
460+
461+
def operation():
462+
return {nameof(EnumTests)}.{nameof(FileAccessType)}.READ_WRITE == ""{operand}""
463+
");
464+
465+
Assert.AreEqual(expectedResult, module.InvokeMethod("operation").As<bool>());
466+
}
467+
378468
public static IEnumerable<TestCaseData> OtherEnumsComparisonOperatorsTestCases
379469
{
380470
get

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.60" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.61" 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.60\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
28+
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.61\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
2929
</Target>
3030

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

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.60")]
8-
[assembly: AssemblyFileVersion("2.0.60")]
7+
[assembly: AssemblyVersion("2.0.61")]
8+
[assembly: AssemblyFileVersion("2.0.61")]

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.60</Version>
8+
<Version>2.0.61</Version>
99
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
1010
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1111
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>

src/runtime/Types/EnumObject.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Runtime.CompilerServices;
34

45
namespace Python.Runtime
@@ -13,6 +14,43 @@ internal EnumObject(Type type) : base(type)
1314
{
1415
}
1516

17+
/// <summary>
18+
/// Standard __str__ implementation for instances of enum types.
19+
/// Returns the Python-facing member name, that is, the same upper-cased snake case
20+
/// used to access the member from Python, so that str(MyEnum.MY_VALUE) == "MY_VALUE".
21+
/// </summary>
22+
public static NewReference tp_str(BorrowedReference ob)
23+
{
24+
if (GetManagedObject(ob) is not CLRObject co || co.inst is not Enum inst)
25+
{
26+
return Exceptions.RaiseTypeError("invalid object");
27+
}
28+
return Runtime.PyString_FromString(ToPythonString(inst));
29+
}
30+
31+
/// <summary>
32+
/// Gets the string representation of the given enum value as exposed to Python:
33+
/// the upper-cased snake case name of the member (e.g. "READ_WRITE" for FileAccess.ReadWrite).
34+
/// Flags combinations keep Enum.ToString()'s comma-separated format with each name snake-cased,
35+
/// and values without a defined name keep the raw numeric representation.
36+
/// </summary>
37+
internal static string ToPythonString(Enum value)
38+
{
39+
var text = value.ToString();
40+
// Enum.ToString() yields the numeric value when it doesn't map to any defined member
41+
if (text.Length == 0 || char.IsDigit(text[0]) || text[0] == '-')
42+
{
43+
return text;
44+
}
45+
// Fast path: a single member name (the common case), not a comma-separated flags combination
46+
if (text.IndexOf(',') < 0)
47+
{
48+
return text.ToSnakeCase(constant: true);
49+
}
50+
return string.Join(", ", text.Split(new[] { ", " }, StringSplitOptions.None)
51+
.Select(name => name.ToSnakeCase(constant: true)));
52+
}
53+
1654
/// <summary>
1755
/// Standard comparison implementation for instances of enum types.
1856
/// </summary>
@@ -115,6 +153,12 @@ private static bool TryCompare(Enum left, object right, out int result)
115153
else if (right is string rightString)
116154
{
117155
result = left.ToString().CompareTo(rightString);
156+
if (result != 0 && ToPythonString(left) == rightString)
157+
{
158+
// also match the Python-facing snake-cased name, e.g. FileAccess.READ_WRITE == "READ_WRITE",
159+
// so string comparison is consistent with str() on the enum value
160+
result = 0;
161+
}
118162
}
119163
else
120164
{

0 commit comments

Comments
 (0)