Skip to content

Commit 32a78eb

Browse files
committed
Make .NET enums real Python IntEnum via make_native_enum; update FracturedJsonOptions to accept Python Enums; add tests
1 parent 67ca590 commit 32a78eb

2 files changed

Lines changed: 131 additions & 38 deletions

File tree

src/fractured_json/__init__.py

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def load_runtime() -> None:
4141
Type,
4242
)
4343
from System.Reflection import BindingFlags # pyright: ignore[reportMissingImports] # noqa: E402
44+
from enum import Enum as PyEnum, IntEnum # Python enum types for interop helpers
4445

4546

4647
def get_object_types() -> dict[str, "System.RuntimeType"]:
@@ -63,53 +64,27 @@ def snake_enum_to_pascal(name: str) -> str:
6364

6465

6566
class NativeEnum:
66-
"""Generic base class that dynamically maps .NET enums to Pythonic attributes."""
67+
"""Compatibility shim: legacy class providing .name and .value for dynamically-created enums.
68+
69+
New code prefers real stdlib `Enum`/`IntEnum` types created with `make_native_enum`,
70+
but this class is preserved for backwards compatibility.
71+
"""
6772

6873
_native_type = None
6974

70-
def __init_subclass__(
71-
cls,
72-
native_type: object | None = None,
73-
**kwargs: dict[str, bool | int | str],
74-
) -> None:
75-
super().__init_subclass__(**kwargs)
76-
77-
# If class is dynamically constructed using type()
78-
if hasattr(cls, "_native_type") and cls._native_type is not None:
79-
native_type = cls._native_type
80-
81-
native_names = [
82-
str(x)
83-
for x in native_type.GetEnumNames() # pyright: ignore[reportAttributeAccessIssue]
84-
]
85-
native_values = [
86-
int(x)
87-
for x in native_type.GetEnumValues() # pyright: ignore[reportAttributeAccessIssue]
88-
]
89-
90-
name_to_value = dict(zip(native_names, native_values, strict=True))
91-
92-
for native_name in native_names:
93-
py_name = to_snake_case(native_name, upper=True)
94-
native_value = name_to_value[native_name]
95-
# Create instance and store on class
96-
instance = cls(py_name, native_value)
97-
setattr(cls, py_name, instance)
75+
def __init__(self, py_name: str, native_value: int) -> None:
76+
# Legacy instance shape (kept for compatibility with older code)
77+
self._py_name = py_name
78+
self._py_value = native_value
9879

9980
@property
10081
def name(self) -> str:
101-
"""The string name of the enum value."""
10282
return self._py_name
10383

10484
@property
10585
def value(self) -> int:
106-
"""The integer value of the enum."""
10786
return self._py_value
10887

109-
def __init__(self, py_name: str, native_value: int) -> None:
110-
self._py_name = py_name
111-
self._py_value = native_value
112-
11388
def __repr__(self) -> str:
11489
return f"{self.__class__.__name__}.{self._py_name}"
11590

@@ -122,6 +97,55 @@ def __hash__(self) -> int:
12297
return hash(self._py_value)
12398

12499

100+
# Cache for created python enum classes keyed by dotnet type representation
101+
_native_enum_cache: dict[str, type] = {}
102+
103+
104+
def make_native_enum(dotnet_type, *, int_enum: bool = True):
105+
"""Create a Python `Enum`/`IntEnum` for a .NET enum type.
106+
107+
The returned class has attached helpers:
108+
- `_native_type` (the original dotnet type)
109+
- `to_dotnet(self)` -> returns a .NET Enum parsed instance when available, otherwise the Pascal-case name
110+
- `from_dotnet(cls, dotnet_value)` -> returns the Python enum member from a .NET value
111+
"""
112+
# Cache by dotnet type to ensure repeated lookups return the same Python class
113+
key = str(dotnet_type)
114+
if key in _native_enum_cache:
115+
return _native_enum_cache[key]
116+
117+
# Delay import to avoid conflicts on module import ordering
118+
from enum import IntEnum
119+
120+
native_names = [str(x) for x in dotnet_type.GetEnumNames()]
121+
native_values = [int(x) for x in dotnet_type.GetEnumValues()]
122+
members = {to_snake_case(n, upper=True): v for n, v in zip(native_names, native_values)}
123+
124+
base = IntEnum if int_enum else PyEnum
125+
cls = base(dotnet_type.Name, members)
126+
cls._native_type = dotnet_type
127+
128+
_native_enum_cache[key] = cls
129+
130+
# Instance -> dotnet conversion (best effort)
131+
def to_dotnet(self):
132+
try:
133+
return Enum.Parse(self._native_type, snake_enum_to_pascal(self.name))
134+
except Exception:
135+
return snake_enum_to_pascal(self.name)
136+
137+
cls.to_dotnet = to_dotnet
138+
139+
@classmethod
140+
def from_dotnet(cls, dotnet_value):
141+
return cls(int(dotnet_value))
142+
143+
cls.from_dotnet = from_dotnet
144+
145+
return cls
146+
147+
148+
125149
types = get_object_types()
126150
FormatterType = types["Formatter"]
127151
FracturedJsonOptionsType = types["FracturedJsonOptions"]
@@ -131,7 +155,7 @@ def __hash__(self) -> int:
131155
"FracturedJsonOptions",
132156
]
133157
for enum_name in [x.Name for x in types.values() if x.IsEnum]:
134-
enum_type = type(enum_name, (NativeEnum,), {"_native_type": types[enum_name]})
158+
enum_type = make_native_enum(types[enum_name], int_enum=True)
135159
globals()[enum_name] = enum_type
136160
__all__.append(enum_type) # noqa: PYI056
137161

@@ -181,8 +205,8 @@ def get(self, name: str) -> int | bool | str | NativeEnum:
181205
prop = self._properties[name]["prop"]
182206
if self._properties[name]["is_enum"]:
183207
native_value = prop.GetValue(self._dotnet_instance)
184-
derived_enum = type(prop.Name, (NativeEnum,), {"_native_type": prop.PropertyType})
185-
return derived_enum(to_snake_case(str(native_value), upper=True), (int(native_value)))
208+
derived_enum = make_native_enum(prop.PropertyType, int_enum=True)
209+
return derived_enum(int(native_value))
186210

187211
return prop.GetValue(self._dotnet_instance)
188212

@@ -214,6 +238,9 @@ def _to_dotnet_type(
214238
return Boolean(value)
215239
if target_type.FullName == "System.String" and isinstance(value, str):
216240
return String(value)
241+
# Accept both legacy `NativeEnum` shim instances and modern Python Enum instances
242+
if target_type.IsEnum and isinstance(value, PyEnum):
243+
return Enum.Parse(prop.PropertyType, snake_enum_to_pascal(value.name))
217244
if target_type.IsEnum and isinstance(value, NativeEnum):
218245
return Enum.Parse(prop.PropertyType, snake_enum_to_pascal(value.name))
219246
if target_type.IsEnum and isinstance(value, str):

tests/test_native_enum.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import inspect
2+
import enum
3+
4+
import fractured_json
5+
6+
7+
def test_comment_policy_is_intenum_and_members():
8+
# Ensure module exposes CommentPolicy converted from .NET enum
9+
CommentPolicy = fractured_json.CommentPolicy
10+
assert inspect.isclass(CommentPolicy)
11+
# Should be a python Enum (IntEnum specifically)
12+
assert issubclass(CommentPolicy, enum.IntEnum)
13+
14+
# Members should be present and convert to expected integer values
15+
assert "TREAT_AS_ERROR" in CommentPolicy.__members__
16+
assert CommentPolicy.TREAT_AS_ERROR.value == 0
17+
assert CommentPolicy.REMOVE.value == 1
18+
assert CommentPolicy.PRESERVE.value == 2
19+
20+
# Lookup by value should return the correct member
21+
assert CommentPolicy(1) is CommentPolicy.REMOVE
22+
23+
# Iteration order is deterministic
24+
members = list(CommentPolicy)
25+
assert members[0] is CommentPolicy.TREAT_AS_ERROR
26+
assert members[1] is CommentPolicy.REMOVE
27+
assert members[2] is CommentPolicy.PRESERVE
28+
29+
30+
def test_options_roundtrip_accepts_pyenum():
31+
# FracturedJsonOptions should accept a Python enum value for enum properties
32+
opts = fractured_json.FracturedJsonOptions()
33+
34+
# Default is TreatAsError
35+
assert opts.comment_policy is fractured_json.CommentPolicy.TREAT_AS_ERROR
36+
37+
# Set using Python enum
38+
opts.comment_policy = fractured_json.CommentPolicy.PRESERVE
39+
assert opts.comment_policy is fractured_json.CommentPolicy.PRESERVE
40+
41+
# Setting via name should still work
42+
opts.set("comment_policy", "Remove")
43+
assert opts.comment_policy is fractured_json.CommentPolicy.REMOVE
44+
45+
46+
def test_make_native_enum_helper_roundtrip():
47+
# Build a fake dotnet enum type mimicking pythonnet's shape
48+
class FakeDotNetEnum:
49+
Name = "ExampleEnum"
50+
51+
@staticmethod
52+
def GetEnumNames():
53+
return ["AlphaBeta", "Gamma"]
54+
55+
@staticmethod
56+
def GetEnumValues():
57+
return [5, 42]
58+
59+
Example = fractured_json.make_native_enum(FakeDotNetEnum, int_enum=True)
60+
assert issubclass(Example, enum.IntEnum)
61+
assert Example.ALPHA_BETA.value == 5
62+
assert Example.GAMMA.value == 42
63+
# from_dotnet should accept numeric values
64+
assert Example.from_dotnet(42) is Example.GAMMA
65+
# to_dotnet returns the Pascal name when dotnet interop isn't available
66+
assert Example.ALPHA_BETA.to_dotnet() == "AlphaBeta"

0 commit comments

Comments
 (0)