Skip to content

Commit 26ad32e

Browse files
committed
test: add integration tests and UI tests for native_enum
- tests/test_native_enum.rs: Rust integration tests covering the full enum protocol (isinstance, name/value, len/iter/contains, lookup by name and value), class and member identity, IntoPyObject/FromPyObject roundtrips, all five base classes (Enum/IntEnum/Flag/IntFlag/StrEnum), rename at class and variant level, module attribute, qualname via direct build_native_enum call, and VariantValue::Str - pytests/src/native_enums.rs + pytests/tests/test_native_enums.py: Python-side tests for the same surface area, exposing Color/Status/ Permission/Bits/Size to Python and verifying enum protocol from pytest - tests/ui/invalid_native_enum_base.rs: UI test for unknown base name - tests/ui/native_enum_generic.rs: UI test for generic/lifetime params (also adds the validation logic in pyo3-macros-backend)
1 parent d1d220f commit 26ad32e

12 files changed

Lines changed: 688 additions & 5 deletions

File tree

newsfragments/6020.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `#[py_native_enum]` / `#[derive(NativeEnum)]` to expose fieldless Rust enums as Python `enum.Enum` subclasses, supporting `Enum`, `IntEnum`, `StrEnum`, `Flag`, and `IntFlag` bases.

pyo3-benches/benches/bench_native_enum.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ enum Status {
2525
// Measures the PyOnceLock cache-hit path: get + clone_ref + into_bound.
2626
fn bench_py_enum_class(b: &mut Bencher<'_>) {
2727
Python::attach(|py| {
28-
b.iter(|| Color::py_enum_class(py).unwrap());
28+
b.iter(|| black_box(Color::py_enum_class(py).unwrap()));
2929
});
3030
}
3131

3232
fn bench_int_enum_class(b: &mut Bencher<'_>) {
3333
Python::attach(|py| {
34-
b.iter(|| Status::py_enum_class(py).unwrap());
34+
b.iter(|| black_box(Status::py_enum_class(py).unwrap()));
3535
});
3636
}
3737

pyo3-macros-backend/src/native_enum.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ fn impl_native_enum(
252252
if let Some((_, expr)) = &variant.discriminant {
253253
let int_val: i64 = extract_discriminant_i64(expr)?;
254254
quote! { #pyo3::native_enum::VariantValue::Int(#int_val) }
255+
} else if base_str == "StrEnum" {
256+
quote! { #pyo3::native_enum::VariantValue::Str(#py_member_name) }
255257
} else {
256258
quote! { #pyo3::native_enum::VariantValue::Auto }
257259
}
@@ -398,6 +400,13 @@ pub fn build_derive_native_enum(input: &mut DeriveInput) -> syn::Result<TokenStr
398400
))
399401
}
400402
};
403+
if let Some(lt) = input.generics.lifetimes().next() {
404+
bail_spanned!(lt.span() => "#[derive(NativeEnum)] cannot have lifetime parameters");
405+
}
406+
ensure_spanned!(
407+
input.generics.params.is_empty(),
408+
input.generics.span() => "#[derive(NativeEnum)] cannot have generic parameters"
409+
);
401410
let args = PyNativeEnumArgs::take_from_attrs(&mut input.attrs)?;
402411
impl_native_enum(&input.ident, &args, &mut data_enum.variants)
403412
}
@@ -407,6 +416,13 @@ pub fn native_enum_impl(
407416
item: &mut syn::ItemEnum,
408417
args: PyNativeEnumArgs,
409418
) -> syn::Result<TokenStream> {
419+
if let Some(lt) = item.generics.lifetimes().next() {
420+
bail_spanned!(lt.span() => "#[native_enum] cannot have lifetime parameters");
421+
}
422+
ensure_spanned!(
423+
item.generics.params.is_empty(),
424+
item.generics.span() => "#[native_enum] cannot have generic parameters"
425+
);
410426
impl_native_enum(&item.ident, &args, &mut item.variants)
411427
}
412428

pytests/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod dict_iter;
1111
mod enums;
1212
mod exception;
1313
mod misc;
14+
mod native_enums;
1415
mod objstore;
1516
mod othermod;
1617
mod path;
@@ -35,9 +36,9 @@ mod pyo3_pytests {
3536
#[pymodule_export]
3637
use {
3738
awaitable::awaitable, comparisons::comparisons, consts::consts, dict_iter::dict_iter,
38-
enums::enums, exception::exception, misc::misc, objstore::objstore, othermod::othermod,
39-
path::path, pyclasses::pyclasses, pyfunctions::pyfunctions, sequence::sequence,
40-
subclassing::subclassing,
39+
enums::enums, exception::exception, misc::misc, native_enums::native_enums,
40+
objstore::objstore, othermod::othermod, path::path, pyclasses::pyclasses,
41+
pyfunctions::pyfunctions, sequence::sequence, subclassing::subclassing,
4142
};
4243

4344
// Inserting to sys.modules allows importing submodules nicely from Python
@@ -52,6 +53,7 @@ mod pyo3_pytests {
5253
sys_modules.set_item("pyo3_pytests.datetime", m.getattr("datetime")?)?;
5354
sys_modules.set_item("pyo3_pytests.dict_iter", m.getattr("dict_iter")?)?;
5455
sys_modules.set_item("pyo3_pytests.enums", m.getattr("enums")?)?;
56+
sys_modules.set_item("pyo3_pytests.native_enums", m.getattr("native_enums")?)?;
5557
sys_modules.set_item("pyo3_pytests.misc", m.getattr("misc")?)?;
5658
sys_modules.set_item("pyo3_pytests.objstore", m.getattr("objstore")?)?;
5759
sys_modules.set_item("pyo3_pytests.othermod", m.getattr("othermod")?)?;

pytests/src/native_enums.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use pyo3::native_enum::NativeEnum;
2+
use pyo3::prelude::*;
3+
use pyo3::py_native_enum;
4+
5+
#[py_native_enum]
6+
pub enum Color {
7+
Red,
8+
Green,
9+
Blue,
10+
}
11+
12+
#[py_native_enum(base = "IntEnum")]
13+
pub enum Status {
14+
Active = 1,
15+
Inactive = 2,
16+
Pending = 3,
17+
}
18+
19+
#[py_native_enum(base = "Flag")]
20+
pub enum Permission {
21+
Read = 1,
22+
Write = 2,
23+
Exec = 4,
24+
}
25+
26+
#[py_native_enum(base = "IntFlag")]
27+
pub enum Bits {
28+
A = 1,
29+
B = 2,
30+
C = 4,
31+
}
32+
33+
#[cfg(Py_3_11)]
34+
#[py_native_enum(base = "StrEnum")]
35+
pub enum Size {
36+
Small,
37+
Medium,
38+
Large,
39+
}
40+
41+
#[pyfunction]
42+
fn identity_bits(b: Bits) -> Bits {
43+
b
44+
}
45+
46+
#[pyfunction]
47+
fn identity_color(c: Color) -> Color {
48+
c
49+
}
50+
51+
#[pyfunction]
52+
fn identity_status(s: Status) -> Status {
53+
s
54+
}
55+
56+
#[pyfunction]
57+
fn identity_permission(p: Permission) -> Permission {
58+
p
59+
}
60+
61+
#[pymodule]
62+
pub mod native_enums {
63+
use super::*;
64+
65+
#[pymodule_init]
66+
fn init(m: &Bound<'_, PyModule>) -> PyResult<()> {
67+
let py = m.py();
68+
m.add("Color", Color::py_enum_class(py)?)?;
69+
m.add("Status", Status::py_enum_class(py)?)?;
70+
m.add("Permission", Permission::py_enum_class(py)?)?;
71+
m.add("Bits", Bits::py_enum_class(py)?)?;
72+
#[cfg(Py_3_11)]
73+
m.add("Size", Size::py_enum_class(py)?)?;
74+
Ok(())
75+
}
76+
77+
#[pymodule_export]
78+
use super::{identity_bits, identity_color, identity_permission, identity_status};
79+
}

pytests/tests/test_native_enums.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import enum
2+
import sys
3+
4+
import pytest
5+
from pyo3_pytests import native_enums
6+
7+
8+
def test_color_is_enum_subclass():
9+
assert issubclass(native_enums.Color, enum.Enum)
10+
11+
12+
def test_color_member_isinstance():
13+
assert isinstance(native_enums.Color.Red, enum.Enum)
14+
assert isinstance(native_enums.Color.Green, enum.Enum)
15+
assert isinstance(native_enums.Color.Blue, enum.Enum)
16+
17+
18+
def test_color_member_isinstance_of_class():
19+
assert isinstance(native_enums.Color.Red, native_enums.Color)
20+
21+
22+
def test_color_name_attribute():
23+
assert native_enums.Color.Red.name == "Red"
24+
assert native_enums.Color.Green.name == "Green"
25+
assert native_enums.Color.Blue.name == "Blue"
26+
27+
28+
def test_color_len():
29+
assert len(native_enums.Color) == 3
30+
31+
32+
def test_color_iter():
33+
members = list(native_enums.Color)
34+
assert members == [native_enums.Color.Red, native_enums.Color.Green, native_enums.Color.Blue]
35+
36+
37+
def test_color_contains():
38+
assert native_enums.Color.Red in native_enums.Color
39+
assert native_enums.Color.Blue in native_enums.Color
40+
41+
42+
def test_color_members_mapping():
43+
assert "Red" in native_enums.Color._member_names_
44+
assert "Green" in native_enums.Color._member_names_
45+
assert "Blue" in native_enums.Color._member_names_
46+
47+
48+
def test_color_lookup_by_name():
49+
assert native_enums.Color["Red"] is native_enums.Color.Red
50+
assert native_enums.Color["Blue"] is native_enums.Color.Blue
51+
52+
53+
def test_color_lookup_by_value():
54+
assert native_enums.Color(native_enums.Color.Red.value) is native_enums.Color.Red
55+
assert native_enums.Color(native_enums.Color.Blue.value) is native_enums.Color.Blue
56+
57+
58+
def test_color_member_identity():
59+
a = native_enums.Color.Green
60+
b = native_enums.Color.Green
61+
assert a is b
62+
63+
64+
def test_color_class_identity():
65+
cls1 = type(native_enums.Color.Red)
66+
cls2 = native_enums.Color
67+
assert cls1 is cls2
68+
69+
70+
def test_identity_color_roundtrip():
71+
result = native_enums.identity_color(native_enums.Color.Red)
72+
assert result is native_enums.Color.Red
73+
74+
75+
@pytest.mark.parametrize("variant", list(native_enums.Color))
76+
def test_identity_color_all_variants(variant):
77+
assert native_enums.identity_color(variant) is variant
78+
79+
80+
def test_status_is_int_enum_subclass():
81+
assert issubclass(native_enums.Status, enum.IntEnum)
82+
assert issubclass(native_enums.Status, int)
83+
84+
85+
def test_status_values():
86+
assert native_enums.Status.Active == 1
87+
assert native_enums.Status.Inactive == 2
88+
assert native_enums.Status.Pending == 3
89+
90+
91+
def test_status_isinstance_int():
92+
assert isinstance(native_enums.Status.Active, int)
93+
94+
95+
def test_status_lookup_by_value():
96+
assert native_enums.Status(1) is native_enums.Status.Active
97+
assert native_enums.Status(2) is native_enums.Status.Inactive
98+
assert native_enums.Status(3) is native_enums.Status.Pending
99+
100+
101+
def test_identity_status_roundtrip():
102+
result = native_enums.identity_status(native_enums.Status.Active)
103+
assert result is native_enums.Status.Active
104+
105+
106+
def test_bits_is_int_flag_subclass():
107+
assert issubclass(native_enums.Bits, enum.IntFlag)
108+
assert issubclass(native_enums.Bits, int)
109+
110+
111+
def test_bits_bitwise_or():
112+
ab = native_enums.Bits.A | native_enums.Bits.B
113+
assert native_enums.Bits.A in ab
114+
assert native_enums.Bits.B in ab
115+
assert native_enums.Bits.C not in ab
116+
117+
118+
def test_bits_isinstance_int():
119+
assert isinstance(native_enums.Bits.A, int)
120+
assert native_enums.Bits.A == 1
121+
assert native_enums.Bits.B == 2
122+
assert native_enums.Bits.C == 4
123+
124+
125+
def test_identity_bits_roundtrip():
126+
result = native_enums.identity_bits(native_enums.Bits.A)
127+
assert result is native_enums.Bits.A
128+
129+
130+
def test_permission_is_flag_subclass():
131+
assert issubclass(native_enums.Permission, enum.Flag)
132+
133+
134+
def test_permission_bitwise_or():
135+
rw = native_enums.Permission.Read | native_enums.Permission.Write
136+
assert native_enums.Permission.Read in rw
137+
assert native_enums.Permission.Write in rw
138+
assert native_enums.Permission.Exec not in rw
139+
140+
141+
def test_permission_bitwise_and():
142+
rw = native_enums.Permission.Read | native_enums.Permission.Write
143+
assert rw & native_enums.Permission.Read == native_enums.Permission.Read
144+
145+
146+
def test_identity_permission_roundtrip():
147+
result = native_enums.identity_permission(native_enums.Permission.Read)
148+
assert result is native_enums.Permission.Read
149+
150+
151+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+")
152+
def test_size_is_str_enum_subclass():
153+
assert issubclass(native_enums.Size, enum.StrEnum)
154+
assert issubclass(native_enums.Size, str)
155+
156+
157+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+")
158+
def test_size_members_are_strings():
159+
assert isinstance(native_enums.Size.Small, str)
160+
assert native_enums.Size.Small == "Small"
161+
assert native_enums.Size.Medium == "Medium"
162+
assert native_enums.Size.Large == "Large"
163+
164+
165+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+")
166+
def test_size_lookup_by_value():
167+
assert native_enums.Size("Small") is native_enums.Size.Small

tests/test_compile_error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,6 @@ fn test_compile_errors() {
9696
t.pass("tests/ui/pyclass_probe.rs");
9797
t.compile_fail("tests/ui/invalid_pyfunction_warn.rs");
9898
t.compile_fail("tests/ui/invalid_pymethods_warn.rs");
99+
t.compile_fail("tests/ui/invalid_native_enum_base.rs");
100+
t.compile_fail("tests/ui/native_enum_generic.rs");
99101
}

0 commit comments

Comments
 (0)