From d1d220f246f2febe7c87d77bcb967fd2797e14fc Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Wed, 29 Apr 2026 12:14:06 +0700 Subject: [PATCH 1/7] Add native_enum: expose Rust enums as Python enum.Enum subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `#[py_native_enum]` / `#[derive(NativeEnum)]` macros that expose a fieldless Rust enum to Python as a true `enum.Enum` subclass via the functional API, without touching the C-API enum machinery (#991). Runtime (`src/native_enum/`): - `spec.rs` – `NativeEnumSpec`, `NativeEnumBase`, `VariantValue` - `base_cache.rs` – per-interpreter `PyOnceLock` cache for base classes (`enum.Enum`, `IntEnum`, `StrEnum`, `Flag`, `IntFlag`) and `enum.auto` - `construct.rs` – `build_native_enum`: builds a Python enum subclass from a spec without caching - `trait_def.rs` – `NativeEnum` trait with `py_enum_class`, `to_py_member`, `from_py_member` Macro (`pyo3-macros-backend/src/native_enum.rs`): - Parses enum-level attrs (`base`, `rename`, `module`) and variant-level attrs (`rename`, `value`); supports integer discriminants - Caches the Python class in a function-local `static PyOnceLock>` in the generated `py_enum_class`, constructing it once per interpreter session - `from_py_member` checks `isinstance(obj, cached_class)` for type safety - Auto-derives `IntoPyObject`, `IntoPyObject for &T`, `FromPyObject` Public API additions to `pyo3-macros/src/lib.rs`: - `#[proc_macro_derive(NativeEnum, attributes(native_enum))]` - `#[proc_macro_attribute] py_native_enum` --- pyo3-benches/Cargo.toml | 4 + pyo3-benches/benches/bench_native_enum.rs | 74 ++++ pyo3-macros-backend/src/attributes.rs | 3 + pyo3-macros-backend/src/lib.rs | 2 + pyo3-macros-backend/src/native_enum.rs | 440 ++++++++++++++++++++++ pyo3-macros/src/lib.rs | 52 ++- src/lib.rs | 11 +- src/native_enum.rs | 63 ++++ src/native_enum/base_cache.rs | 38 ++ src/native_enum/construct.rs | 52 +++ src/native_enum/spec.rs | 54 +++ src/native_enum/trait_def.rs | 63 ++++ 12 files changed, 852 insertions(+), 4 deletions(-) create mode 100644 pyo3-benches/benches/bench_native_enum.rs create mode 100644 pyo3-macros-backend/src/native_enum.rs create mode 100644 src/native_enum.rs create mode 100644 src/native_enum/base_cache.rs create mode 100644 src/native_enum/construct.rs create mode 100644 src/native_enum/spec.rs create mode 100644 src/native_enum/trait_def.rs diff --git a/pyo3-benches/Cargo.toml b/pyo3-benches/Cargo.toml index 3ade8ef00e9..d9d5cd5ea60 100644 --- a/pyo3-benches/Cargo.toml +++ b/pyo3-benches/Cargo.toml @@ -86,6 +86,10 @@ harness = false name = "bench_intern" harness = false +[[bench]] +name = "bench_native_enum" +harness = false + [[bench]] name = "bench_extract" harness = false diff --git a/pyo3-benches/benches/bench_native_enum.rs b/pyo3-benches/benches/bench_native_enum.rs new file mode 100644 index 00000000000..df3587240f2 --- /dev/null +++ b/pyo3-benches/benches/bench_native_enum.rs @@ -0,0 +1,74 @@ +use std::hint::black_box; + +use codspeed_criterion_compat::{criterion_group, criterion_main, Bencher, Criterion}; + +use pyo3::native_enum::NativeEnum; +use pyo3::prelude::*; +use pyo3::py_native_enum; + +/// A simple enum using the default `enum.Enum` base. +#[py_native_enum] +enum Color { + Red, + Green, + Blue, +} + +/// An integer enum using `enum.IntEnum`. +#[py_native_enum(base = "IntEnum")] +enum Status { + Active, + Inactive, + Pending, +} + +// Measures the PyOnceLock cache-hit path: get + clone_ref + into_bound. +fn bench_py_enum_class(b: &mut Bencher<'_>) { + Python::attach(|py| { + b.iter(|| Color::py_enum_class(py).unwrap()); + }); +} + +fn bench_int_enum_class(b: &mut Bencher<'_>) { + Python::attach(|py| { + b.iter(|| Status::py_enum_class(py).unwrap()); + }); +} + +fn bench_to_py_member(b: &mut Bencher<'_>) { + Python::attach(|py| { + b.iter_with_large_drop(|| Color::Green.to_py_member(py).unwrap()); + }); +} + +fn bench_int_enum_to_py_member(b: &mut Bencher<'_>) { + Python::attach(|py| { + b.iter_with_large_drop(|| Status::Active.to_py_member(py).unwrap()); + }); +} + +fn bench_from_py_member(b: &mut Bencher<'_>) { + Python::attach(|py| { + let obj = Color::Blue.to_py_member(py).unwrap(); + b.iter(|| Color::from_py_member(black_box(&obj)).unwrap()); + }); +} + +fn bench_int_enum_from_py_member(b: &mut Bencher<'_>) { + Python::attach(|py| { + let obj = Status::Pending.to_py_member(py).unwrap(); + b.iter(|| Status::from_py_member(black_box(&obj)).unwrap()); + }); +} + +fn criterion_benchmark(c: &mut Criterion) { + c.bench_function("native_enum_py_enum_class", bench_py_enum_class); + c.bench_function("native_enum_int_enum_class", bench_int_enum_class); + c.bench_function("native_enum_to_py_member", bench_to_py_member); + c.bench_function("native_enum_int_enum_to_py_member", bench_int_enum_to_py_member); + c.bench_function("native_enum_from_py_member", bench_from_py_member); + c.bench_function("native_enum_int_enum_from_py_member", bench_int_enum_from_py_member); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 9894c463628..1347de3c488 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -56,6 +56,9 @@ pub mod kw { syn::custom_keyword!(category); syn::custom_keyword!(from_py_object); syn::custom_keyword!(skip_from_py_object); + syn::custom_keyword!(base); + syn::custom_keyword!(rename); + syn::custom_keyword!(value); } fn take_int(read: &mut &str, tracker: &mut usize) -> String { diff --git a/pyo3-macros-backend/src/lib.rs b/pyo3-macros-backend/src/lib.rs index a90fa73678e..edd5d69b4d8 100644 --- a/pyo3-macros-backend/src/lib.rs +++ b/pyo3-macros-backend/src/lib.rs @@ -18,6 +18,7 @@ mod introspection; mod konst; mod method; mod module; +mod native_enum; mod params; #[cfg(feature = "experimental-inspect")] mod py_expr; @@ -30,6 +31,7 @@ mod quotes; pub use frompyobject::build_derive_from_pyobject; pub use intopyobject::build_derive_into_pyobject; pub use module::{pymodule_function_impl, pymodule_module_impl, PyModuleOptions}; +pub use native_enum::{build_derive_native_enum, native_enum_impl, PyNativeEnumArgs}; pub use pyclass::{build_py_class, build_py_enum, PyClassArgs}; pub use pyfunction::{build_py_function, PyFunctionOptions}; pub use pyimpl::{build_py_methods, PyClassMethodsType}; diff --git a/pyo3-macros-backend/src/native_enum.rs b/pyo3-macros-backend/src/native_enum.rs new file mode 100644 index 00000000000..ef0209fb89f --- /dev/null +++ b/pyo3-macros-backend/src/native_enum.rs @@ -0,0 +1,440 @@ +use crate::attributes::{kw, take_attributes, CrateAttribute, KeywordAttribute, ModuleAttribute}; +use crate::utils::Ctx; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::Parse, parse::ParseStream, punctuated::Punctuated, spanned::Spanned, Data, DeriveInput, + Expr, Fields, LitInt, LitStr, Token, +}; + +type BaseAttribute = KeywordAttribute; +type RenameAttribute = KeywordAttribute; + +enum NativeEnumOption { + Base(BaseAttribute), + Rename(RenameAttribute), + Module(ModuleAttribute), + Crate(CrateAttribute), +} + +impl syn::parse::Parse for NativeEnumOption { + fn parse(input: ParseStream<'_>) -> syn::Result { + let lookahead = input.lookahead1(); + if lookahead.peek(kw::base) { + input.parse().map(NativeEnumOption::Base) + } else if lookahead.peek(kw::rename) { + input.parse().map(NativeEnumOption::Rename) + } else if lookahead.peek(kw::module) { + input.parse().map(NativeEnumOption::Module) + } else if lookahead.peek(Token![crate]) { + input.parse().map(NativeEnumOption::Crate) + } else { + Err(lookahead.error()) + } + } +} + +/// Parsed arguments for the `#[native_enum(...)]` attribute and `#[derive(NativeEnum)]` macro. +#[derive(Default)] +pub struct PyNativeEnumArgs { + base: Option, + rename: Option, + module: Option, + krate: Option, +} + +impl PyNativeEnumArgs { + fn set_option(&mut self, option: NativeEnumOption) -> syn::Result<()> { + macro_rules! set_option { + ($key:ident) => {{ + ensure_spanned!( + self.$key.is_none(), + $key.kw.span() => concat!("`", stringify!($key), "` may only be specified once") + ); + self.$key = Some($key); + }}; + } + match option { + NativeEnumOption::Base(base) => set_option!(base), + NativeEnumOption::Rename(rename) => set_option!(rename), + NativeEnumOption::Module(module) => set_option!(module), + NativeEnumOption::Crate(krate) => set_option!(krate), + } + Ok(()) + } + + fn take_from_attrs(attrs: &mut Vec) -> syn::Result { + let mut result = Self::default(); + take_attributes(attrs, |attr| { + if !attr.path().is_ident("native_enum") { + return Ok(false); + } + for option in + attr.parse_args_with(Punctuated::::parse_terminated)? + { + result.set_option(option)?; + } + Ok(true) + })?; + Ok(result) + } +} + +impl Parse for PyNativeEnumArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut result = Self::default(); + for option in Punctuated::::parse_terminated(input)? { + result.set_option(option)?; + } + Ok(result) + } +} + +/// The right-hand side of `value = ...`: either an integer or a string literal. +enum ValueLit { + Int(i64), + Str(String), +} + +impl syn::parse::Parse for ValueLit { + fn parse(input: ParseStream<'_>) -> syn::Result { + let lookahead = input.lookahead1(); + if lookahead.peek(LitInt) { + let lit: LitInt = input.parse()?; + lit.base10_parse::().map(ValueLit::Int) + } else if lookahead.peek(LitStr) { + input.parse::().map(|s| ValueLit::Str(s.value())) + } else { + Err(lookahead.error()) + } + } +} + +type VariantRenameAttribute = KeywordAttribute; +type VariantValueAttribute = KeywordAttribute; + +enum NativeEnumVariantOption { + Rename(VariantRenameAttribute), + Value(VariantValueAttribute), +} + +impl syn::parse::Parse for NativeEnumVariantOption { + fn parse(input: ParseStream<'_>) -> syn::Result { + let lookahead = input.lookahead1(); + if lookahead.peek(kw::rename) { + input.parse().map(NativeEnumVariantOption::Rename) + } else if lookahead.peek(kw::value) { + input.parse().map(NativeEnumVariantOption::Value) + } else { + Err(lookahead.error()) + } + } +} + +#[derive(Default)] +struct VariantAttrs { + rename: Option, + value: Option, +} + +impl VariantAttrs { + fn set_option(&mut self, option: NativeEnumVariantOption) -> syn::Result<()> { + macro_rules! set_option { + ($key:ident) => {{ + ensure_spanned!( + self.$key.is_none(), + $key.kw.span() => concat!("`", stringify!($key), "` may only be specified once") + ); + self.$key = Some($key); + }}; + } + match option { + NativeEnumVariantOption::Rename(rename) => set_option!(rename), + NativeEnumVariantOption::Value(value) => set_option!(value), + } + Ok(()) + } + + fn take_from_attrs(attrs: &mut Vec) -> syn::Result { + let mut result = Self::default(); + take_attributes(attrs, |attr| { + if !attr.path().is_ident("native_enum") { + return Ok(false); + } + for option in attr.parse_args_with( + Punctuated::::parse_terminated, + )? { + result.set_option(option)?; + } + Ok(true) + })?; + Ok(result) + } +} + +fn impl_native_enum( + ident: &syn::Ident, + args: &PyNativeEnumArgs, + variants: &mut Punctuated, +) -> syn::Result { + let ctx = Ctx::new(&args.krate, None); + let pyo3 = &ctx.pyo3_path; + + let py_name = args + .rename + .as_ref() + .map(|r| r.value.value()) + .unwrap_or_else(|| ident.to_string()); + + let module_opt = match &args.module { + Some(m) => { + let s = &m.value; + quote! { ::std::option::Option::Some(#s) } + } + None => quote! { ::std::option::Option::None }, + }; + + let base_str = args + .base + .as_ref() + .map(|b| b.value.value()) + .unwrap_or_else(|| "Enum".to_string()); + + let base_expr = match base_str.as_str() { + "Enum" => quote! { #pyo3::native_enum::NativeEnumBase::Enum }, + "IntEnum" => quote! { #pyo3::native_enum::NativeEnumBase::IntEnum }, + "StrEnum" => quote! { #pyo3::native_enum::NativeEnumBase::StrEnum }, + "Flag" => quote! { #pyo3::native_enum::NativeEnumBase::Flag }, + "IntFlag" => quote! { #pyo3::native_enum::NativeEnumBase::IntFlag }, + other => { + let span = args + .base + .as_ref() + .map(|b| b.value.span()) + .unwrap_or_else(|| ident.span()); + return Err(syn::Error::new( + span, + format!( + "unknown native_enum base: `{other}`. \ + Expected one of: Enum, IntEnum, StrEnum, Flag, IntFlag" + ), + )); + } + }; + + let mut variant_specs: Vec = Vec::new(); + let mut into_arms: Vec = Vec::new(); + let mut from_arms: Vec = Vec::new(); + + for variant in variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "NativeEnum variants must be fieldless (unit variants)", + )); + } + + let variant_attrs = VariantAttrs::take_from_attrs(&mut variant.attrs)?; + let rust_name = variant.ident.to_string(); + let py_member_name = variant_attrs + .rename + .as_ref() + .map(|r| r.value.value()) + .unwrap_or_else(|| rust_name.clone()); + let variant_ident = &variant.ident; + + let value_expr = match &variant_attrs.value { + Some(v) => match &v.value { + ValueLit::Int(i) => quote! { #pyo3::native_enum::VariantValue::Int(#i) }, + ValueLit::Str(s) => quote! { #pyo3::native_enum::VariantValue::Str(#s) }, + }, + None => { + if let Some((_, expr)) = &variant.discriminant { + let int_val: i64 = extract_discriminant_i64(expr)?; + quote! { #pyo3::native_enum::VariantValue::Int(#int_val) } + } else { + quote! { #pyo3::native_enum::VariantValue::Auto } + } + } + }; + + variant_specs.push(quote! { (#py_member_name, #value_expr) }); + into_arms.push(quote! { + Self::#variant_ident => #pyo3::intern!(py, #py_member_name), + }); + from_arms.push(quote! { + #py_member_name => ::std::result::Result::Ok(Self::#variant_ident), + }); + } + + let str_enum_cfg_check = if base_str == "StrEnum" { + quote! { + #[cfg(not(Py_3_11))] + const _: () = { + ::std::compile_error!( + "NativeEnum with `base = \"StrEnum\"` requires Python 3.11 or later" + ); + }; + } + } else { + quote! {} + }; + + Ok(quote! { + #str_enum_cfg_check + + #[automatically_derived] + impl #pyo3::native_enum::NativeEnum for #ident { + const SPEC: #pyo3::native_enum::NativeEnumSpec = #pyo3::native_enum::NativeEnumSpec { + name: #py_name, + base: #base_expr, + variants: &[#(#variant_specs),*], + module: #module_opt, + qualname: ::std::option::Option::None, + }; + + fn py_enum_class( + py: #pyo3::Python<'_>, + ) -> #pyo3::PyResult<#pyo3::Bound<'_, #pyo3::types::PyType>> { + static PY_CLASS: #pyo3::sync::PyOnceLock<#pyo3::Py<#pyo3::types::PyType>> = + #pyo3::sync::PyOnceLock::new(); + PY_CLASS + .get_or_try_init(py, || { + #pyo3::native_enum::build_native_enum(py, &Self::SPEC) + .map(|cls| cls.unbind()) + }) + .map(|cls| cls.clone_ref(py).into_bound(py)) + } + + fn to_py_member<'py>( + &self, + py: #pyo3::Python<'py>, + ) -> #pyo3::PyResult<#pyo3::Bound<'py, #pyo3::types::PyAny>> { + let cls = ::py_enum_class(py)?; + let name = match self { + #(#into_arms)* + }; + #pyo3::types::PyAnyMethods::getattr(cls.as_any(), name).map_err(::std::convert::Into::into) + } + + fn from_py_member( + obj: &#pyo3::Bound<'_, #pyo3::types::PyAny>, + ) -> #pyo3::PyResult { + let cls = ::py_enum_class(obj.py())?; + if !#pyo3::types::PyAnyMethods::is_instance(obj, cls.as_any())? { + return ::std::result::Result::Err( + #pyo3::exceptions::PyTypeError::new_err( + ::std::format!("expected a `{}` member", #py_name) + ) + ); + } + let name_obj = #pyo3::types::PyAnyMethods::getattr( + obj, + #pyo3::intern!(obj.py(), "name"), + )?; + let name = #pyo3::types::PyAnyMethods::extract::<&str>( + &name_obj + )?; + match name { + #(#from_arms)* + other => ::std::result::Result::Err( + #pyo3::exceptions::PyValueError::new_err( + ::std::format!("unknown `{}` variant: {}", #py_name, other) + ) + ), + } + } + } + + #[automatically_derived] + impl<'py> #pyo3::IntoPyObject<'py> for #ident { + type Target = #pyo3::types::PyAny; + type Output = #pyo3::Bound<'py, Self::Target>; + type Error = #pyo3::PyErr; + + fn into_pyobject( + self, + py: #pyo3::Python<'py>, + ) -> ::std::result::Result { + #pyo3::native_enum::NativeEnum::to_py_member(&self, py) + } + } + + #[automatically_derived] + impl<'py> #pyo3::IntoPyObject<'py> for &#ident { + type Target = #pyo3::types::PyAny; + type Output = #pyo3::Bound<'py, Self::Target>; + type Error = #pyo3::PyErr; + + fn into_pyobject( + self, + py: #pyo3::Python<'py>, + ) -> ::std::result::Result { + #pyo3::native_enum::NativeEnum::to_py_member(self, py) + } + } + + #[automatically_derived] + impl<'py> #pyo3::FromPyObject<'_, 'py> for #ident { + type Error = #pyo3::PyErr; + + fn extract( + obj: #pyo3::Borrowed<'_, 'py, #pyo3::types::PyAny>, + ) -> ::std::result::Result { + #pyo3::native_enum::NativeEnum::from_py_member(&*obj) + } + } + }) +} + +/// Entry point for `#[derive(NativeEnum)]`. +pub fn build_derive_native_enum(input: &mut DeriveInput) -> syn::Result { + let data_enum = match &mut input.data { + Data::Enum(data) => data, + _ => { + return Err(syn::Error::new_spanned( + &*input, + "NativeEnum can only be derived for enums", + )) + } + }; + let args = PyNativeEnumArgs::take_from_attrs(&mut input.attrs)?; + impl_native_enum(&input.ident, &args, &mut data_enum.variants) +} + +/// Entry point for `#[native_enum(...)]` attribute macro. +pub fn native_enum_impl( + item: &mut syn::ItemEnum, + args: PyNativeEnumArgs, +) -> syn::Result { + impl_native_enum(&item.ident, &args, &mut item.variants) +} + +/// Extracts an integer literal from a simple discriminant expression. +/// +/// Only supports integer literals and negated integer literals. Complex expressions +/// must use `#[native_enum(value = N)]`. +fn extract_discriminant_i64(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(expr_lit) => { + if let syn::Lit::Int(lit) = &expr_lit.lit { + return lit.base10_parse::(); + } + } + Expr::Unary(expr_unary) => { + if matches!(expr_unary.op, syn::UnOp::Neg(_)) { + if let Expr::Lit(inner) = &*expr_unary.expr { + if let syn::Lit::Int(lit) = &inner.lit { + return lit.base10_parse::().map(|v| -v); + } + } + } + } + _ => {} + } + Err(syn::Error::new_spanned( + expr, + "NativeEnum only supports integer literal discriminants; \ + use `#[native_enum(value = N)]` for complex expressions", + )) +} diff --git a/pyo3-macros/src/lib.rs b/pyo3-macros/src/lib.rs index bba07366b3c..ef969ccc852 100644 --- a/pyo3-macros/src/lib.rs +++ b/pyo3-macros/src/lib.rs @@ -5,9 +5,10 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use pyo3_macros_backend::{ - build_derive_from_pyobject, build_derive_into_pyobject, build_py_class, build_py_enum, - build_py_function, build_py_methods, pymodule_function_impl, pymodule_module_impl, PyClassArgs, - PyClassMethodsType, PyFunctionOptions, PyModuleOptions, + build_derive_from_pyobject, build_derive_into_pyobject, build_derive_native_enum, + build_py_class, build_py_enum, build_py_function, build_py_methods, native_enum_impl, + pymodule_function_impl, pymodule_module_impl, PyClassArgs, PyClassMethodsType, + PyFunctionOptions, PyModuleOptions, PyNativeEnumArgs, }; use quote::quote; use syn::{parse_macro_input, Item}; @@ -188,6 +189,51 @@ pub fn derive_from_py_object(item: TokenStream) -> TokenStream { .into() } +/// Derives [`NativeEnum`] for a fieldless Rust enum, exposing it to Python as an +/// [`enum.Enum`] subclass. +/// +/// Supported enum-level attributes via `#[native_enum(...)]`: +/// - `base = "IntEnum"` — choose the Python base class (default: `"Enum"`) +/// - `rename = "PyName"` — override the Python class name (default: Rust ident) +/// - `module = "my_module"` — set the `module` kwarg on the functional API +/// +/// Supported variant-level attributes via `#[native_enum(...)]`: +/// - `rename = "py_name"` — override this member's Python name +/// - `value = 42` or `value = "str"` — explicit value (overrides Rust discriminant) +/// +/// [`NativeEnum`]: pyo3::native_enum::NativeEnum +/// [`enum.Enum`]: https://docs.python.org/3/library/enum.html +#[proc_macro_derive(NativeEnum, attributes(native_enum))] +pub fn derive_native_enum(item: TokenStream) -> TokenStream { + let mut ast = parse_macro_input!(item as syn::DeriveInput); + let expanded = build_derive_native_enum(&mut ast).unwrap_or_compile_error(); + quote!(#expanded).into() +} + +/// Attribute macro form of `#[derive(NativeEnum)]`, consistent with the `#[pyclass]` style. +/// +/// Supported options via `#[py_native_enum(...)]`: +/// - `base = "IntEnum"` — choose the Python base class (default: `"Enum"`) +/// - `rename = "PyName"` — override the Python class name (default: Rust ident) +/// - `module = "my_module"` — set the `module` kwarg on the functional API +/// +/// Supported per-variant options via `#[native_enum(...)]`: +/// - `rename = "py_name"` — override this member's Python name +/// - `value = 42` or `value = "str"` — explicit value +/// +/// [`enum.Enum`]: https://docs.python.org/3/library/enum.html +#[proc_macro_attribute] +pub fn py_native_enum(attr: TokenStream, input: TokenStream) -> TokenStream { + let mut ast = parse_macro_input!(input as syn::ItemEnum); + let args = parse_macro_input!(attr as PyNativeEnumArgs); + let expanded = native_enum_impl(&mut ast, args).unwrap_or_compile_error(); + quote!( + #ast + #expanded + ) + .into() +} + fn pyclass_impl( attrs: TokenStream, mut ast: syn::ItemStruct, diff --git a/src/lib.rs b/src/lib.rs index 50692aa4af7..fd96bc93a21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -423,6 +423,7 @@ mod instance; mod interpreter_lifecycle; pub mod marker; pub mod marshal; +pub mod native_enum; #[macro_use] pub mod sync; pub(crate) mod byteswriter; @@ -444,9 +445,17 @@ pub use crate::conversions::*; #[cfg(feature = "macros")] pub use pyo3_macros::{ - pyfunction, pymethods, pymodule, FromPyObject, IntoPyObject, IntoPyObjectRef, + pyfunction, pymethods, pymodule, FromPyObject, IntoPyObject, IntoPyObjectRef, NativeEnum, }; +/// A proc macro used to expose a Rust enum to Python as an [`enum.Enum`] subclass. +/// +/// This is the attribute macro form of [`NativeEnum`], consistent with the [`pyclass`] style. +/// +/// [`enum.Enum`]: https://docs.python.org/3/library/enum.html +#[cfg(feature = "macros")] +pub use pyo3_macros::py_native_enum; + /// A proc macro used to expose Rust structs and fieldless enums as Python objects. /// #[doc = include_str!("../guide/pyclass-parameters.md")] diff --git a/src/native_enum.rs b/src/native_enum.rs new file mode 100644 index 00000000000..b06a5e6d999 --- /dev/null +++ b/src/native_enum.rs @@ -0,0 +1,63 @@ +//! Support for exposing Rust enums to Python as [`enum.Enum`] subclasses. +//! +//! The `enum` base class (e.g. `enum.Enum`, `enum.IntEnum`) is cached per interpreter session +//! using [`PyOnceLock`], while the generated Python class itself is **not** cached here — it is +//! cached per enum type by the `#[native_enum]` / `#[derive(NativeEnum)]` macros. +//! +//! # Quick start +//! +//! ```rust,ignore +//! use pyo3::native_enum::{NativeEnum, NativeEnumBase, NativeEnumSpec, VariantValue}; +//! use pyo3::prelude::*; +//! use pyo3::types::PyAny; +//! +//! #[derive(Copy, Clone)] +//! enum Color { Red, Green, Blue } +//! +//! impl NativeEnum for Color { +//! const SPEC: NativeEnumSpec = NativeEnumSpec { +//! name: "Color", +//! base: NativeEnumBase::Enum, +//! variants: &[ +//! ("Red", VariantValue::Auto), +//! ("Green", VariantValue::Auto), +//! ("Blue", VariantValue::Auto), +//! ], +//! module: None, +//! qualname: None, +//! }; +//! +//! fn to_py_member<'py>(&self, py: Python<'py>) -> PyResult> { +//! let cls = Self::py_enum_class(py)?; +//! let name = match self { Self::Red => "Red", Self::Green => "Green", Self::Blue => "Blue" }; +//! cls.getattr(name).map_err(Into::into) +//! } +//! +//! fn from_py_member(obj: &Bound<'_, PyAny>) -> PyResult { +//! use pyo3::exceptions::{PyTypeError, PyValueError}; +//! let cls = Self::py_enum_class(obj.py())?; +//! if !obj.is_instance(cls.as_any())? { +//! return Err(PyTypeError::new_err("expected a Color member")); +//! } +//! let name: String = obj.getattr("name")?.extract()?; +//! match name.as_str() { +//! "Red" => Ok(Self::Red), +//! "Green" => Ok(Self::Green), +//! "Blue" => Ok(Self::Blue), +//! other => Err(PyValueError::new_err(format!("unknown Color variant: {other}"))), +//! } +//! } +//! } +//! ``` +//! +//! [`enum.Enum`]: https://docs.python.org/3/library/enum.html +//! [`PyOnceLock`]: crate::sync::PyOnceLock + +mod base_cache; +mod construct; +mod spec; +mod trait_def; + +pub use self::construct::build_native_enum; +pub use self::spec::{NativeEnumBase, NativeEnumSpec, VariantValue}; +pub use self::trait_def::NativeEnum; diff --git a/src/native_enum/base_cache.rs b/src/native_enum/base_cache.rs new file mode 100644 index 00000000000..f50e82d9361 --- /dev/null +++ b/src/native_enum/base_cache.rs @@ -0,0 +1,38 @@ +use crate::exceptions::PyImportError; +use crate::sync::PyOnceLock; +use crate::types::{PyAny, PyType}; +use crate::{Bound, Py, PyResult, Python}; + +use super::spec::NativeEnumBase; + +static ENUM_BASE: PyOnceLock> = PyOnceLock::new(); +static INT_ENUM_BASE: PyOnceLock> = PyOnceLock::new(); +static STR_ENUM_BASE: PyOnceLock> = PyOnceLock::new(); +static FLAG_BASE: PyOnceLock> = PyOnceLock::new(); +static INT_FLAG_BASE: PyOnceLock> = PyOnceLock::new(); +static ENUM_AUTO_FN: PyOnceLock> = PyOnceLock::new(); + +/// Returns the cached Python base class for `base`, importing `enum` at most once. +pub(crate) fn get_cached_base( + py: Python<'_>, + base: NativeEnumBase, +) -> PyResult<&Bound<'_, PyType>> { + if base == NativeEnumBase::StrEnum && py.version_info() < (3, 11) { + return Err(PyImportError::new_err( + "StrEnum requires Python 3.11 or later", + )); + } + let cell = match base { + NativeEnumBase::Enum => &ENUM_BASE, + NativeEnumBase::IntEnum => &INT_ENUM_BASE, + NativeEnumBase::StrEnum => &STR_ENUM_BASE, + NativeEnumBase::Flag => &FLAG_BASE, + NativeEnumBase::IntFlag => &INT_FLAG_BASE, + }; + cell.import(py, "enum", base.class_name()) +} + +/// Returns the cached `enum.auto` callable. +pub(crate) fn get_enum_auto(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> { + ENUM_AUTO_FN.import(py, "enum", "auto") +} diff --git a/src/native_enum/construct.rs b/src/native_enum/construct.rs new file mode 100644 index 00000000000..a57904c1c21 --- /dev/null +++ b/src/native_enum/construct.rs @@ -0,0 +1,52 @@ +use crate::types::any::PyAnyMethods; +use crate::types::dict::PyDictMethods; +use crate::types::{PyAny, PyDict, PyList, PyTuple, PyType}; +use crate::{Bound, IntoPyObject, PyResult, Python}; + +use super::base_cache::{get_cached_base, get_enum_auto}; +use super::spec::{NativeEnumSpec, VariantValue}; + +/// Builds a Python `enum` subclass from `spec` without caching the result. +/// +/// The base class (e.g. `enum.Enum`) is retrieved from a per-interpreter cache, so +/// the `enum` module is imported only once. The generated class itself is **not** cached; +/// each call returns a freshly constructed `Bound<'py, PyType>`. +pub fn build_native_enum<'py>( + py: Python<'py>, + spec: &NativeEnumSpec, +) -> PyResult> { + let base_cls = get_cached_base(py, spec.base)?; + + let members: Vec> = spec + .variants + .iter() + .map(|(variant_name, value)| { + let py_value: Bound<'py, PyAny> = match value { + VariantValue::Int(v) => v.into_pyobject(py)?.into_any(), + VariantValue::Str(s) => s.into_pyobject(py)?.into_any(), + VariantValue::Auto => get_enum_auto(py)?.call0()?, + }; + let name_obj = variant_name.into_pyobject(py)?.into_any(); + PyTuple::new(py, [name_obj, py_value]) + }) + .collect::>()?; + + let members_list = PyList::new(py, members)?; + let name_arg = spec.name.into_pyobject(py)?.into_any(); + let args = PyTuple::new(py, [name_arg, members_list.into_any()])?; + + let class = if spec.module.is_some() || spec.qualname.is_some() { + let kwargs = PyDict::new(py); + if let Some(m) = spec.module { + kwargs.set_item("module", m)?; + } + if let Some(q) = spec.qualname { + kwargs.set_item("qualname", q)?; + } + base_cls.call(args, Some(&kwargs))? + } else { + base_cls.call1(args)? + }; + + class.cast_into::().map_err(Into::into) +} diff --git a/src/native_enum/spec.rs b/src/native_enum/spec.rs new file mode 100644 index 00000000000..d6824108c24 --- /dev/null +++ b/src/native_enum/spec.rs @@ -0,0 +1,54 @@ +/// The Python `enum` base class to use when building a native enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeEnumBase { + /// `enum.Enum` — general-purpose enum. + Enum, + /// `enum.IntEnum` — enum whose members are also integers. + IntEnum, + /// `enum.StrEnum` — enum whose members are also strings (Python 3.11+). + StrEnum, + /// `enum.Flag` — enum supporting bitwise operations. + Flag, + /// `enum.IntFlag` — integer-valued enum supporting bitwise operations. + IntFlag, +} + +impl NativeEnumBase { + pub(crate) fn class_name(self) -> &'static str { + match self { + Self::Enum => "Enum", + Self::IntEnum => "IntEnum", + Self::StrEnum => "StrEnum", + Self::Flag => "Flag", + Self::IntFlag => "IntFlag", + } + } +} + +/// The value assigned to an enum variant when building the Python class. +#[derive(Debug, Clone, Copy)] +pub enum VariantValue { + /// A fixed integer value. + Int(i64), + /// A fixed string value. + Str(&'static str), + /// Use `enum.auto()` to assign the value automatically. + Auto, +} + +/// Static specification used by [`build_native_enum`] to construct a Python `enum` subclass. +/// +/// [`build_native_enum`]: super::build_native_enum +#[derive(Debug, Clone, Copy)] +pub struct NativeEnumSpec { + /// The name passed as the first argument to the Python `enum` functional API. + pub name: &'static str, + /// The Python base class to subclass. + pub base: NativeEnumBase, + /// Ordered list of `(variant_name, value)` pairs. + pub variants: &'static [(&'static str, VariantValue)], + /// Optional `module` keyword argument forwarded to the functional API. + pub module: Option<&'static str>, + /// Optional `qualname` keyword argument forwarded to the functional API. + pub qualname: Option<&'static str>, +} diff --git a/src/native_enum/trait_def.rs b/src/native_enum/trait_def.rs new file mode 100644 index 00000000000..c259d1211fc --- /dev/null +++ b/src/native_enum/trait_def.rs @@ -0,0 +1,63 @@ +use crate::types::{PyAny, PyType}; +use crate::{Bound, PyResult, Python}; + +use super::construct::build_native_enum; +use super::spec::NativeEnumSpec; + +/// A Rust enum that can be exposed to Python as a `enum.Enum` subclass. +/// +/// Implement this trait (or derive it with `#[derive(NativeEnum)]`) to enable conversion +/// between a Rust enum and its Python counterpart. +/// +/// # Class caching +/// +/// `#[derive(NativeEnum)]` generates a `py_enum_class` override that stores the Python +/// class in a `PyOnceLock`, constructing it only once per interpreter session. +/// +/// The **default** `py_enum_class` provided by this trait does **not** cache — it calls +/// [`build_native_enum`] on every invocation, which happens inside `to_py_member` and +/// `from_py_member`. If you implement this trait manually, override `py_enum_class` with +/// a cached version to avoid reconstructing the Python class on every conversion: +/// +/// ```rust,ignore +/// use pyo3::native_enum::{build_native_enum, NativeEnum, NativeEnumSpec}; +/// use pyo3::sync::PyOnceLock; +/// use pyo3::types::PyType; +/// use pyo3::{Bound, Py, PyResult, Python}; +/// +/// static MY_ENUM_CLASS: PyOnceLock> = PyOnceLock::new(); +/// +/// impl NativeEnum for MyEnum { +/// const SPEC: NativeEnumSpec = /* ... */; +/// +/// fn py_enum_class(py: Python<'_>) -> PyResult> { +/// MY_ENUM_CLASS +/// .get_or_try_init(py, || { +/// build_native_enum(py, &Self::SPEC).map(|cls| cls.unbind()) +/// }) +/// .map(|cls| cls.clone_ref(py).into_bound(py)) +/// } +/// +/// // implement to_py_member and from_py_member ... +/// } +/// ``` +/// +/// [`build_native_enum`]: super::build_native_enum +pub trait NativeEnum: Sized + 'static { + /// Static specification describing how to build the Python class. + const SPEC: NativeEnumSpec; + + /// Builds and returns the Python `enum` subclass for this type. + /// + /// **Uncached by default** — override with a `PyOnceLock`-based implementation when + /// implementing the trait manually. See the [trait-level docs](NativeEnum) for an example. + fn py_enum_class(py: Python<'_>) -> PyResult> { + build_native_enum(py, &Self::SPEC) + } + + /// Converts `self` into the corresponding Python enum member. + fn to_py_member<'py>(&self, py: Python<'py>) -> PyResult>; + + /// Extracts `Self` from a Python enum member. + fn from_py_member(obj: &Bound<'_, PyAny>) -> PyResult; +} From 26ad32ea2bd237ecd395d46fc56978c4e5057c4f Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 00:24:10 +0700 Subject: [PATCH 2/7] 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) --- newsfragments/6020.added.md | 1 + pyo3-benches/benches/bench_native_enum.rs | 4 +- pyo3-macros-backend/src/native_enum.rs | 16 + pytests/src/lib.rs | 8 +- pytests/src/native_enums.rs | 79 +++++ pytests/tests/test_native_enums.py | 167 ++++++++++ tests/test_compile_error.rs | 2 + tests/test_native_enum.rs | 374 ++++++++++++++++++++++ tests/ui/invalid_native_enum_base.rs | 9 + tests/ui/invalid_native_enum_base.stderr | 5 + tests/ui/native_enum_generic.rs | 17 + tests/ui/native_enum_generic.stderr | 11 + 12 files changed, 688 insertions(+), 5 deletions(-) create mode 100644 newsfragments/6020.added.md create mode 100644 pytests/src/native_enums.rs create mode 100644 pytests/tests/test_native_enums.py create mode 100644 tests/test_native_enum.rs create mode 100644 tests/ui/invalid_native_enum_base.rs create mode 100644 tests/ui/invalid_native_enum_base.stderr create mode 100644 tests/ui/native_enum_generic.rs create mode 100644 tests/ui/native_enum_generic.stderr diff --git a/newsfragments/6020.added.md b/newsfragments/6020.added.md new file mode 100644 index 00000000000..23de3eccfac --- /dev/null +++ b/newsfragments/6020.added.md @@ -0,0 +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. diff --git a/pyo3-benches/benches/bench_native_enum.rs b/pyo3-benches/benches/bench_native_enum.rs index df3587240f2..8a5c81682ba 100644 --- a/pyo3-benches/benches/bench_native_enum.rs +++ b/pyo3-benches/benches/bench_native_enum.rs @@ -25,13 +25,13 @@ enum Status { // Measures the PyOnceLock cache-hit path: get + clone_ref + into_bound. fn bench_py_enum_class(b: &mut Bencher<'_>) { Python::attach(|py| { - b.iter(|| Color::py_enum_class(py).unwrap()); + b.iter(|| black_box(Color::py_enum_class(py).unwrap())); }); } fn bench_int_enum_class(b: &mut Bencher<'_>) { Python::attach(|py| { - b.iter(|| Status::py_enum_class(py).unwrap()); + b.iter(|| black_box(Status::py_enum_class(py).unwrap())); }); } diff --git a/pyo3-macros-backend/src/native_enum.rs b/pyo3-macros-backend/src/native_enum.rs index ef0209fb89f..45f0b6de71d 100644 --- a/pyo3-macros-backend/src/native_enum.rs +++ b/pyo3-macros-backend/src/native_enum.rs @@ -252,6 +252,8 @@ fn impl_native_enum( if let Some((_, expr)) = &variant.discriminant { let int_val: i64 = extract_discriminant_i64(expr)?; quote! { #pyo3::native_enum::VariantValue::Int(#int_val) } + } else if base_str == "StrEnum" { + quote! { #pyo3::native_enum::VariantValue::Str(#py_member_name) } } else { quote! { #pyo3::native_enum::VariantValue::Auto } } @@ -398,6 +400,13 @@ pub fn build_derive_native_enum(input: &mut DeriveInput) -> syn::Result "#[derive(NativeEnum)] cannot have lifetime parameters"); + } + ensure_spanned!( + input.generics.params.is_empty(), + input.generics.span() => "#[derive(NativeEnum)] cannot have generic parameters" + ); let args = PyNativeEnumArgs::take_from_attrs(&mut input.attrs)?; impl_native_enum(&input.ident, &args, &mut data_enum.variants) } @@ -407,6 +416,13 @@ pub fn native_enum_impl( item: &mut syn::ItemEnum, args: PyNativeEnumArgs, ) -> syn::Result { + if let Some(lt) = item.generics.lifetimes().next() { + bail_spanned!(lt.span() => "#[native_enum] cannot have lifetime parameters"); + } + ensure_spanned!( + item.generics.params.is_empty(), + item.generics.span() => "#[native_enum] cannot have generic parameters" + ); impl_native_enum(&item.ident, &args, &mut item.variants) } diff --git a/pytests/src/lib.rs b/pytests/src/lib.rs index f6f4b151e6e..bfbb9c9640d 100644 --- a/pytests/src/lib.rs +++ b/pytests/src/lib.rs @@ -11,6 +11,7 @@ mod dict_iter; mod enums; mod exception; mod misc; +mod native_enums; mod objstore; mod othermod; mod path; @@ -35,9 +36,9 @@ mod pyo3_pytests { #[pymodule_export] use { awaitable::awaitable, comparisons::comparisons, consts::consts, dict_iter::dict_iter, - enums::enums, exception::exception, misc::misc, objstore::objstore, othermod::othermod, - path::path, pyclasses::pyclasses, pyfunctions::pyfunctions, sequence::sequence, - subclassing::subclassing, + enums::enums, exception::exception, misc::misc, native_enums::native_enums, + objstore::objstore, othermod::othermod, path::path, pyclasses::pyclasses, + pyfunctions::pyfunctions, sequence::sequence, subclassing::subclassing, }; // Inserting to sys.modules allows importing submodules nicely from Python @@ -52,6 +53,7 @@ mod pyo3_pytests { sys_modules.set_item("pyo3_pytests.datetime", m.getattr("datetime")?)?; sys_modules.set_item("pyo3_pytests.dict_iter", m.getattr("dict_iter")?)?; sys_modules.set_item("pyo3_pytests.enums", m.getattr("enums")?)?; + sys_modules.set_item("pyo3_pytests.native_enums", m.getattr("native_enums")?)?; sys_modules.set_item("pyo3_pytests.misc", m.getattr("misc")?)?; sys_modules.set_item("pyo3_pytests.objstore", m.getattr("objstore")?)?; sys_modules.set_item("pyo3_pytests.othermod", m.getattr("othermod")?)?; diff --git a/pytests/src/native_enums.rs b/pytests/src/native_enums.rs new file mode 100644 index 00000000000..6d03fd1e9af --- /dev/null +++ b/pytests/src/native_enums.rs @@ -0,0 +1,79 @@ +use pyo3::native_enum::NativeEnum; +use pyo3::prelude::*; +use pyo3::py_native_enum; + +#[py_native_enum] +pub enum Color { + Red, + Green, + Blue, +} + +#[py_native_enum(base = "IntEnum")] +pub enum Status { + Active = 1, + Inactive = 2, + Pending = 3, +} + +#[py_native_enum(base = "Flag")] +pub enum Permission { + Read = 1, + Write = 2, + Exec = 4, +} + +#[py_native_enum(base = "IntFlag")] +pub enum Bits { + A = 1, + B = 2, + C = 4, +} + +#[cfg(Py_3_11)] +#[py_native_enum(base = "StrEnum")] +pub enum Size { + Small, + Medium, + Large, +} + +#[pyfunction] +fn identity_bits(b: Bits) -> Bits { + b +} + +#[pyfunction] +fn identity_color(c: Color) -> Color { + c +} + +#[pyfunction] +fn identity_status(s: Status) -> Status { + s +} + +#[pyfunction] +fn identity_permission(p: Permission) -> Permission { + p +} + +#[pymodule] +pub mod native_enums { + use super::*; + + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + let py = m.py(); + m.add("Color", Color::py_enum_class(py)?)?; + m.add("Status", Status::py_enum_class(py)?)?; + m.add("Permission", Permission::py_enum_class(py)?)?; + m.add("Bits", Bits::py_enum_class(py)?)?; + #[cfg(Py_3_11)] + m.add("Size", Size::py_enum_class(py)?)?; + Ok(()) + } + + #[pymodule_export] + use super::{identity_bits, identity_color, identity_permission, identity_status}; +} diff --git a/pytests/tests/test_native_enums.py b/pytests/tests/test_native_enums.py new file mode 100644 index 00000000000..e4eaa137b40 --- /dev/null +++ b/pytests/tests/test_native_enums.py @@ -0,0 +1,167 @@ +import enum +import sys + +import pytest +from pyo3_pytests import native_enums + + +def test_color_is_enum_subclass(): + assert issubclass(native_enums.Color, enum.Enum) + + +def test_color_member_isinstance(): + assert isinstance(native_enums.Color.Red, enum.Enum) + assert isinstance(native_enums.Color.Green, enum.Enum) + assert isinstance(native_enums.Color.Blue, enum.Enum) + + +def test_color_member_isinstance_of_class(): + assert isinstance(native_enums.Color.Red, native_enums.Color) + + +def test_color_name_attribute(): + assert native_enums.Color.Red.name == "Red" + assert native_enums.Color.Green.name == "Green" + assert native_enums.Color.Blue.name == "Blue" + + +def test_color_len(): + assert len(native_enums.Color) == 3 + + +def test_color_iter(): + members = list(native_enums.Color) + assert members == [native_enums.Color.Red, native_enums.Color.Green, native_enums.Color.Blue] + + +def test_color_contains(): + assert native_enums.Color.Red in native_enums.Color + assert native_enums.Color.Blue in native_enums.Color + + +def test_color_members_mapping(): + assert "Red" in native_enums.Color._member_names_ + assert "Green" in native_enums.Color._member_names_ + assert "Blue" in native_enums.Color._member_names_ + + +def test_color_lookup_by_name(): + assert native_enums.Color["Red"] is native_enums.Color.Red + assert native_enums.Color["Blue"] is native_enums.Color.Blue + + +def test_color_lookup_by_value(): + assert native_enums.Color(native_enums.Color.Red.value) is native_enums.Color.Red + assert native_enums.Color(native_enums.Color.Blue.value) is native_enums.Color.Blue + + +def test_color_member_identity(): + a = native_enums.Color.Green + b = native_enums.Color.Green + assert a is b + + +def test_color_class_identity(): + cls1 = type(native_enums.Color.Red) + cls2 = native_enums.Color + assert cls1 is cls2 + + +def test_identity_color_roundtrip(): + result = native_enums.identity_color(native_enums.Color.Red) + assert result is native_enums.Color.Red + + +@pytest.mark.parametrize("variant", list(native_enums.Color)) +def test_identity_color_all_variants(variant): + assert native_enums.identity_color(variant) is variant + + +def test_status_is_int_enum_subclass(): + assert issubclass(native_enums.Status, enum.IntEnum) + assert issubclass(native_enums.Status, int) + + +def test_status_values(): + assert native_enums.Status.Active == 1 + assert native_enums.Status.Inactive == 2 + assert native_enums.Status.Pending == 3 + + +def test_status_isinstance_int(): + assert isinstance(native_enums.Status.Active, int) + + +def test_status_lookup_by_value(): + assert native_enums.Status(1) is native_enums.Status.Active + assert native_enums.Status(2) is native_enums.Status.Inactive + assert native_enums.Status(3) is native_enums.Status.Pending + + +def test_identity_status_roundtrip(): + result = native_enums.identity_status(native_enums.Status.Active) + assert result is native_enums.Status.Active + + +def test_bits_is_int_flag_subclass(): + assert issubclass(native_enums.Bits, enum.IntFlag) + assert issubclass(native_enums.Bits, int) + + +def test_bits_bitwise_or(): + ab = native_enums.Bits.A | native_enums.Bits.B + assert native_enums.Bits.A in ab + assert native_enums.Bits.B in ab + assert native_enums.Bits.C not in ab + + +def test_bits_isinstance_int(): + assert isinstance(native_enums.Bits.A, int) + assert native_enums.Bits.A == 1 + assert native_enums.Bits.B == 2 + assert native_enums.Bits.C == 4 + + +def test_identity_bits_roundtrip(): + result = native_enums.identity_bits(native_enums.Bits.A) + assert result is native_enums.Bits.A + + +def test_permission_is_flag_subclass(): + assert issubclass(native_enums.Permission, enum.Flag) + + +def test_permission_bitwise_or(): + rw = native_enums.Permission.Read | native_enums.Permission.Write + assert native_enums.Permission.Read in rw + assert native_enums.Permission.Write in rw + assert native_enums.Permission.Exec not in rw + + +def test_permission_bitwise_and(): + rw = native_enums.Permission.Read | native_enums.Permission.Write + assert rw & native_enums.Permission.Read == native_enums.Permission.Read + + +def test_identity_permission_roundtrip(): + result = native_enums.identity_permission(native_enums.Permission.Read) + assert result is native_enums.Permission.Read + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+") +def test_size_is_str_enum_subclass(): + assert issubclass(native_enums.Size, enum.StrEnum) + assert issubclass(native_enums.Size, str) + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+") +def test_size_members_are_strings(): + assert isinstance(native_enums.Size.Small, str) + assert native_enums.Size.Small == "Small" + assert native_enums.Size.Medium == "Medium" + assert native_enums.Size.Large == "Large" + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="StrEnum requires Python 3.11+") +def test_size_lookup_by_value(): + assert native_enums.Size("Small") is native_enums.Size.Small diff --git a/tests/test_compile_error.rs b/tests/test_compile_error.rs index e5b646239d7..3999054b8f9 100644 --- a/tests/test_compile_error.rs +++ b/tests/test_compile_error.rs @@ -96,4 +96,6 @@ fn test_compile_errors() { t.pass("tests/ui/pyclass_probe.rs"); t.compile_fail("tests/ui/invalid_pyfunction_warn.rs"); t.compile_fail("tests/ui/invalid_pymethods_warn.rs"); + t.compile_fail("tests/ui/invalid_native_enum_base.rs"); + t.compile_fail("tests/ui/native_enum_generic.rs"); } diff --git a/tests/test_native_enum.rs b/tests/test_native_enum.rs new file mode 100644 index 00000000000..cfda3a48eda --- /dev/null +++ b/tests/test_native_enum.rs @@ -0,0 +1,374 @@ +#![cfg(feature = "macros")] + +use pyo3::native_enum::NativeEnum; +use pyo3::prelude::*; +use pyo3::py_native_enum; +use pyo3::py_run; + +#[py_native_enum] +enum Color { + Red, + Green, + Blue, +} + +#[py_native_enum(base = "IntEnum")] +enum Status { + Active = 1, + Inactive = 2, + Pending = 3, +} + +#[py_native_enum(base = "Flag")] +enum Permission { + Read = 1, + Write = 2, + Exec = 4, +} + +#[py_native_enum(base = "IntFlag")] +enum Bits { + A = 1, + B = 2, + C = 4, +} + +#[py_native_enum(rename = "Colour")] +enum Colour { + Red, + Green, +} + +#[py_native_enum] +enum Named { + #[native_enum(rename = "FIRST")] + First, + Second, +} + +#[py_native_enum(module = "mymod")] +enum Modded { + A, + B, +} + +#[pyfunction] +fn accept_color(c: Color) -> Color { + c +} + +#[test] +fn test_isinstance_enum() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + let red = Color::Red.to_py_member(py).unwrap(); + py_run!(py, cls red, r#" + import enum + assert isinstance(red, enum.Enum) + assert isinstance(red, cls) + "#); + }); +} + +#[test] +fn test_enum_name_and_value() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert cls.Red.name == "Red" + assert cls.Green.name == "Green" + assert cls.Blue.name == "Blue" + "# + ); + }); +} + +#[test] +fn test_enum_len_iter_contains() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert len(cls) == 3 + members = list(cls) + assert members == [cls.Red, cls.Green, cls.Blue] + assert cls.Red in cls + assert cls.Blue in cls + "# + ); + }); +} + +#[test] +fn test_enum_members_mapping() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert "Red" in cls._member_names_ + assert "Green" in cls._member_names_ + assert "Blue" in cls._member_names_ + assert len(cls._member_names_) == 3 + "# + ); + }); +} + +#[test] +fn test_enum_lookup_by_name() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert cls["Red"] is cls.Red + assert cls["Green"] is cls.Green + assert cls["Blue"] is cls.Blue + "# + ); + }); +} + +#[test] +fn test_enum_lookup_by_value() { + Python::attach(|py| { + let cls = Color::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert cls(cls.Red.value) is cls.Red + assert cls(cls.Blue.value) is cls.Blue + "# + ); + }); +} + +#[test] +fn test_class_identity() { + Python::attach(|py| { + let cls1 = Color::py_enum_class(py).unwrap(); + let cls2 = Color::py_enum_class(py).unwrap(); + py_run!(py, cls1 cls2, "assert cls1 is cls2"); + }); +} + +#[test] +fn test_member_identity() { + Python::attach(|py| { + let red1 = Color::Red.to_py_member(py).unwrap(); + let red2 = Color::Red.to_py_member(py).unwrap(); + py_run!(py, red1 red2, "assert red1 is red2"); + }); +} + +#[test] +fn test_into_pyobject() { + Python::attach(|py| { + let obj = Color::Green.into_pyobject(py).unwrap(); + let cls = Color::py_enum_class(py).unwrap(); + py_run!(py, obj cls, "assert obj is cls.Green"); + }); +} + +#[test] +fn test_from_py_object() { + Python::attach(|py| { + let blue = Color::Blue.to_py_member(py).unwrap(); + let extracted: Color = blue.extract().unwrap(); + assert!(matches!(extracted, Color::Blue)); + }); +} + +#[test] +fn test_pyfunction_roundtrip() { + Python::attach(|py| { + let f = wrap_pyfunction!(accept_color)(py).unwrap(); + let cls = Color::py_enum_class(py).unwrap(); + py_run!(py, f cls, r#" + result = f(cls.Red) + assert result is cls.Red + "#); + }); +} + +#[test] +fn test_int_enum_isinstance() { + Python::attach(|py| { + let cls = Status::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + import enum + assert isinstance(cls.Active, enum.IntEnum) + assert isinstance(cls.Active, int) + "# + ); + }); +} + +#[test] +fn test_int_enum_values() { + Python::attach(|py| { + let cls = Status::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert cls.Active == 1 + assert cls.Inactive == 2 + assert cls.Pending == 3 + assert cls(1) is cls.Active + assert cls(2) is cls.Inactive + "# + ); + }); +} + +#[test] +fn test_flag_enum() { + Python::attach(|py| { + let cls = Permission::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + import enum + assert isinstance(cls.Read, enum.Flag) + rw = cls.Read | cls.Write + assert cls.Read in rw + assert cls.Write in rw + assert cls.Exec not in rw + "# + ); + }); +} + +#[test] +fn test_rename_class() { + Python::attach(|py| { + let cls = Colour::py_enum_class(py).unwrap(); + py_run!(py, cls, "assert cls.__name__ == 'Colour'"); + }); +} + +#[test] +fn test_rename_variant() { + Python::attach(|py| { + let cls = Named::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert hasattr(cls, "FIRST") + assert cls.FIRST.name == "FIRST" + assert cls.Second.name == "Second" + "# + ); + }); +} + +#[test] +fn test_module_attribute() { + Python::attach(|py| { + let cls = Modded::py_enum_class(py).unwrap(); + py_run!(py, cls, "assert cls.__module__ == 'mymod'"); + }); +} + +#[test] +fn test_int_flag_enum() { + Python::attach(|py| { + let cls = Bits::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + import enum + assert isinstance(cls.A, enum.IntFlag) + assert isinstance(cls.A, int) + ab = cls.A | cls.B + assert cls.A in ab + assert cls.B in ab + assert cls.C not in ab + "# + ); + }); +} + +#[test] +fn test_build_native_enum_qualname() { + use pyo3::native_enum::{build_native_enum, NativeEnumBase, NativeEnumSpec, VariantValue}; + Python::attach(|py| { + let spec = NativeEnumSpec { + name: "Inner", + base: NativeEnumBase::Enum, + variants: &[("A", VariantValue::Auto), ("B", VariantValue::Auto)], + module: None, + qualname: Some("Outer.Inner"), + }; + let cls = build_native_enum(py, &spec).unwrap(); + py_run!(py, cls, "assert cls.__qualname__ == 'Outer.Inner'"); + }); +} + +#[cfg(Py_3_11)] +#[test] +fn test_str_enum() { + #[py_native_enum(base = "StrEnum")] + enum Size { + Small, + Medium, + Large, + } + + Python::attach(|py| { + let cls = Size::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + import enum + assert isinstance(cls.Small, enum.StrEnum) + assert isinstance(cls.Small, str) + assert cls.Small == "Small" + assert cls.Medium == "Medium" + assert cls.Large == "Large" + "# + ); + }); +} + +#[cfg(Py_3_11)] +#[test] +fn test_str_variant_explicit_value() { + #[py_native_enum(base = "StrEnum")] + enum Tag { + #[native_enum(value = "tag-alpha")] + Alpha, + #[native_enum(value = "tag-beta")] + Beta, + } + + Python::attach(|py| { + let cls = Tag::py_enum_class(py).unwrap(); + py_run!( + py, + cls, + r#" + assert cls.Alpha.value == "tag-alpha" + assert cls.Beta.value == "tag-beta" + assert cls("tag-alpha") is cls.Alpha + "# + ); + }); +} diff --git a/tests/ui/invalid_native_enum_base.rs b/tests/ui/invalid_native_enum_base.rs new file mode 100644 index 00000000000..49580e1e5d4 --- /dev/null +++ b/tests/ui/invalid_native_enum_base.rs @@ -0,0 +1,9 @@ +use pyo3::py_native_enum; + +#[py_native_enum(base = "NotABase")] +enum Foo { + A, + B, +} + +fn main() {} diff --git a/tests/ui/invalid_native_enum_base.stderr b/tests/ui/invalid_native_enum_base.stderr new file mode 100644 index 00000000000..d23179246aa --- /dev/null +++ b/tests/ui/invalid_native_enum_base.stderr @@ -0,0 +1,5 @@ +error: unknown native_enum base: `NotABase`. Expected one of: Enum, IntEnum, StrEnum, Flag, IntFlag + --> tests/ui/invalid_native_enum_base.rs:3:25 + | +3 | #[py_native_enum(base = "NotABase")] + | ^^^^^^^^^^ diff --git a/tests/ui/native_enum_generic.rs b/tests/ui/native_enum_generic.rs new file mode 100644 index 00000000000..8d15e846d24 --- /dev/null +++ b/tests/ui/native_enum_generic.rs @@ -0,0 +1,17 @@ +use pyo3::py_native_enum; + +#[derive(pyo3::NativeEnum)] +enum WithTypeParam { + A, + B, + _Phantom(std::marker::PhantomData), +} + +#[py_native_enum] +enum WithLifetime<'a> { + A, + B, + _Phantom(&'a ()), +} + +fn main() {} diff --git a/tests/ui/native_enum_generic.stderr b/tests/ui/native_enum_generic.stderr new file mode 100644 index 00000000000..f17882da745 --- /dev/null +++ b/tests/ui/native_enum_generic.stderr @@ -0,0 +1,11 @@ +error: #[derive(NativeEnum)] cannot have generic parameters + --> tests/ui/native_enum_generic.rs:4:19 + | +4 | enum WithTypeParam { + | ^ + +error: #[native_enum] cannot have lifetime parameters + --> tests/ui/native_enum_generic.rs:11:19 + | +11 | enum WithLifetime<'a> { + | ^^ From 976939c43eb4019311e9743c51670b01a31ff3b9 Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 01:18:50 +0700 Subject: [PATCH 3/7] fix: resolve CI failures (ruff format + rustdoc ambiguous link) - Apply ruff formatting to test_native_enums.py (line wrapping) - Disambiguate `pyclass` doc link with `macro@pyclass` prefix --- pytests/tests/test_native_enums.py | 6 +++++- src/lib.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pytests/tests/test_native_enums.py b/pytests/tests/test_native_enums.py index e4eaa137b40..a1cb53c8490 100644 --- a/pytests/tests/test_native_enums.py +++ b/pytests/tests/test_native_enums.py @@ -31,7 +31,11 @@ def test_color_len(): def test_color_iter(): members = list(native_enums.Color) - assert members == [native_enums.Color.Red, native_enums.Color.Green, native_enums.Color.Blue] + assert members == [ + native_enums.Color.Red, + native_enums.Color.Green, + native_enums.Color.Blue, + ] def test_color_contains(): diff --git a/src/lib.rs b/src/lib.rs index fd96bc93a21..93662d658a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -450,7 +450,7 @@ pub use pyo3_macros::{ /// A proc macro used to expose a Rust enum to Python as an [`enum.Enum`] subclass. /// -/// This is the attribute macro form of [`NativeEnum`], consistent with the [`pyclass`] style. +/// This is the attribute macro form of [`NativeEnum`], consistent with the [`macro@pyclass`] style. /// /// [`enum.Enum`]: https://docs.python.org/3/library/enum.html #[cfg(feature = "macros")] From ad12c50bf0ffa8ca4bdf7853053131e2c9e7c53f Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 01:25:04 +0700 Subject: [PATCH 4/7] fix: use URL for NativeEnum doc link in proc-macro crate Proc-macro crates cannot resolve intra-doc links to other crates. Replace `pyo3::native_enum::NativeEnum` with a docs.rs URL. --- pyo3-macros/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyo3-macros/src/lib.rs b/pyo3-macros/src/lib.rs index ef969ccc852..7e225e5aea0 100644 --- a/pyo3-macros/src/lib.rs +++ b/pyo3-macros/src/lib.rs @@ -201,7 +201,7 @@ pub fn derive_from_py_object(item: TokenStream) -> TokenStream { /// - `rename = "py_name"` — override this member's Python name /// - `value = 42` or `value = "str"` — explicit value (overrides Rust discriminant) /// -/// [`NativeEnum`]: pyo3::native_enum::NativeEnum +/// [`NativeEnum`]: https://docs.rs/pyo3/latest/pyo3/native_enum/trait.NativeEnum.html /// [`enum.Enum`]: https://docs.python.org/3/library/enum.html #[proc_macro_derive(NativeEnum, attributes(native_enum))] pub fn derive_native_enum(item: TokenStream) -> TokenStream { From 18dcacb2cefdbdb3a34aae7ddc80f0ce4a24f44e Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 01:39:34 +0700 Subject: [PATCH 5/7] fix: add native_enums.pyi stub for introspection test The test-introspection CI job generates .pyi stubs and compares them against expected files in pytests/stubs/. The native_enums module was missing its stub file. Co-Authored-By: Claude Opus 4.6 --- pytests/stubs/native_enums.pyi | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 pytests/stubs/native_enums.pyi diff --git a/pytests/stubs/native_enums.pyi b/pytests/stubs/native_enums.pyi new file mode 100644 index 00000000000..d8987217898 --- /dev/null +++ b/pytests/stubs/native_enums.pyi @@ -0,0 +1,7 @@ +from _typeshed import Incomplete + +def identity_bits(b: Incomplete) -> Incomplete: ... +def identity_color(c: Incomplete) -> Incomplete: ... +def identity_permission(p: Incomplete) -> Incomplete: ... +def identity_status(s: Incomplete) -> Incomplete: ... +def __getattr__(name: str) -> Incomplete: ... From 1dbd0c04f758e691b0741ee4748cea8a11144c55 Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 02:03:21 +0700 Subject: [PATCH 6/7] fix: use String instead of &str in FromPyObject for abi3 compatibility `&str: FromPyObject` is gated by `#[cfg(any(Py_3_10, not(Py_LIMITED_API)))]`, making it unavailable under abi3-py39. Use `String` which is always available. --- pyo3-macros-backend/src/native_enum.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyo3-macros-backend/src/native_enum.rs b/pyo3-macros-backend/src/native_enum.rs index 45f0b6de71d..3b7ca4d59a3 100644 --- a/pyo3-macros-backend/src/native_enum.rs +++ b/pyo3-macros-backend/src/native_enum.rs @@ -334,10 +334,10 @@ fn impl_native_enum( obj, #pyo3::intern!(obj.py(), "name"), )?; - let name = #pyo3::types::PyAnyMethods::extract::<&str>( + let name = #pyo3::types::PyAnyMethods::extract::<::std::string::String>( &name_obj )?; - match name { + match name.as_str() { #(#from_arms)* other => ::std::result::Result::Err( #pyo3::exceptions::PyValueError::new_err( From c0f95ea9313e072b66cc10cd49baacb94b95deb4 Mon Sep 17 00:00:00 2001 From: tetsuyawakita Date: Fri, 8 May 2026 02:29:33 +0700 Subject: [PATCH 7/7] refactor: use `name` instead of `rename` for native_enum attributes Align with `#[pyclass(name = "...")]` convention. Both enum-level and variant-level attributes now use `name` instead of `rename`. Remove unused `kw::rename` custom keyword. --- pyo3-macros-backend/src/attributes.rs | 1 - pyo3-macros-backend/src/native_enum.rs | 38 +++++++++++++------------- pyo3-macros/src/lib.rs | 8 +++--- tests/test_native_enum.rs | 8 +++--- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 1347de3c488..3171e2813d8 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -57,7 +57,6 @@ pub mod kw { syn::custom_keyword!(from_py_object); syn::custom_keyword!(skip_from_py_object); syn::custom_keyword!(base); - syn::custom_keyword!(rename); syn::custom_keyword!(value); } diff --git a/pyo3-macros-backend/src/native_enum.rs b/pyo3-macros-backend/src/native_enum.rs index 3b7ca4d59a3..3e6d8d5fd5b 100644 --- a/pyo3-macros-backend/src/native_enum.rs +++ b/pyo3-macros-backend/src/native_enum.rs @@ -1,4 +1,6 @@ -use crate::attributes::{kw, take_attributes, CrateAttribute, KeywordAttribute, ModuleAttribute}; +use crate::attributes::{ + kw, take_attributes, CrateAttribute, KeywordAttribute, ModuleAttribute, NameAttribute, +}; use crate::utils::Ctx; use proc_macro2::TokenStream; use quote::quote; @@ -8,11 +10,10 @@ use syn::{ }; type BaseAttribute = KeywordAttribute; -type RenameAttribute = KeywordAttribute; enum NativeEnumOption { Base(BaseAttribute), - Rename(RenameAttribute), + Name(NameAttribute), Module(ModuleAttribute), Crate(CrateAttribute), } @@ -22,8 +23,8 @@ impl syn::parse::Parse for NativeEnumOption { let lookahead = input.lookahead1(); if lookahead.peek(kw::base) { input.parse().map(NativeEnumOption::Base) - } else if lookahead.peek(kw::rename) { - input.parse().map(NativeEnumOption::Rename) + } else if lookahead.peek(kw::name) { + input.parse().map(NativeEnumOption::Name) } else if lookahead.peek(kw::module) { input.parse().map(NativeEnumOption::Module) } else if lookahead.peek(Token![crate]) { @@ -38,7 +39,7 @@ impl syn::parse::Parse for NativeEnumOption { #[derive(Default)] pub struct PyNativeEnumArgs { base: Option, - rename: Option, + name: Option, module: Option, krate: Option, } @@ -56,7 +57,7 @@ impl PyNativeEnumArgs { } match option { NativeEnumOption::Base(base) => set_option!(base), - NativeEnumOption::Rename(rename) => set_option!(rename), + NativeEnumOption::Name(name) => set_option!(name), NativeEnumOption::Module(module) => set_option!(module), NativeEnumOption::Crate(krate) => set_option!(krate), } @@ -96,7 +97,7 @@ enum ValueLit { Str(String), } -impl syn::parse::Parse for ValueLit { +impl Parse for ValueLit { fn parse(input: ParseStream<'_>) -> syn::Result { let lookahead = input.lookahead1(); if lookahead.peek(LitInt) { @@ -110,19 +111,18 @@ impl syn::parse::Parse for ValueLit { } } -type VariantRenameAttribute = KeywordAttribute; type VariantValueAttribute = KeywordAttribute; enum NativeEnumVariantOption { - Rename(VariantRenameAttribute), + Name(NameAttribute), Value(VariantValueAttribute), } -impl syn::parse::Parse for NativeEnumVariantOption { +impl Parse for NativeEnumVariantOption { fn parse(input: ParseStream<'_>) -> syn::Result { let lookahead = input.lookahead1(); - if lookahead.peek(kw::rename) { - input.parse().map(NativeEnumVariantOption::Rename) + if lookahead.peek(kw::name) { + input.parse().map(NativeEnumVariantOption::Name) } else if lookahead.peek(kw::value) { input.parse().map(NativeEnumVariantOption::Value) } else { @@ -133,7 +133,7 @@ impl syn::parse::Parse for NativeEnumVariantOption { #[derive(Default)] struct VariantAttrs { - rename: Option, + name: Option, value: Option, } @@ -149,7 +149,7 @@ impl VariantAttrs { }}; } match option { - NativeEnumVariantOption::Rename(rename) => set_option!(rename), + NativeEnumVariantOption::Name(name) => set_option!(name), NativeEnumVariantOption::Value(value) => set_option!(value), } Ok(()) @@ -181,9 +181,9 @@ fn impl_native_enum( let pyo3 = &ctx.pyo3_path; let py_name = args - .rename + .name .as_ref() - .map(|r| r.value.value()) + .map(|n| n.value.0.to_string()) .unwrap_or_else(|| ident.to_string()); let module_opt = match &args.module { @@ -237,9 +237,9 @@ fn impl_native_enum( let variant_attrs = VariantAttrs::take_from_attrs(&mut variant.attrs)?; let rust_name = variant.ident.to_string(); let py_member_name = variant_attrs - .rename + .name .as_ref() - .map(|r| r.value.value()) + .map(|n| n.value.0.to_string()) .unwrap_or_else(|| rust_name.clone()); let variant_ident = &variant.ident; diff --git a/pyo3-macros/src/lib.rs b/pyo3-macros/src/lib.rs index 7e225e5aea0..dfce98448ff 100644 --- a/pyo3-macros/src/lib.rs +++ b/pyo3-macros/src/lib.rs @@ -194,11 +194,11 @@ pub fn derive_from_py_object(item: TokenStream) -> TokenStream { /// /// Supported enum-level attributes via `#[native_enum(...)]`: /// - `base = "IntEnum"` — choose the Python base class (default: `"Enum"`) -/// - `rename = "PyName"` — override the Python class name (default: Rust ident) +/// - `name = "PyName"` — override the Python class name (default: Rust ident) /// - `module = "my_module"` — set the `module` kwarg on the functional API /// /// Supported variant-level attributes via `#[native_enum(...)]`: -/// - `rename = "py_name"` — override this member's Python name +/// - `name = "py_name"` — override this member's Python name /// - `value = 42` or `value = "str"` — explicit value (overrides Rust discriminant) /// /// [`NativeEnum`]: https://docs.rs/pyo3/latest/pyo3/native_enum/trait.NativeEnum.html @@ -214,11 +214,11 @@ pub fn derive_native_enum(item: TokenStream) -> TokenStream { /// /// Supported options via `#[py_native_enum(...)]`: /// - `base = "IntEnum"` — choose the Python base class (default: `"Enum"`) -/// - `rename = "PyName"` — override the Python class name (default: Rust ident) +/// - `name = "PyName"` — override the Python class name (default: Rust ident) /// - `module = "my_module"` — set the `module` kwarg on the functional API /// /// Supported per-variant options via `#[native_enum(...)]`: -/// - `rename = "py_name"` — override this member's Python name +/// - `name = "py_name"` — override this member's Python name /// - `value = 42` or `value = "str"` — explicit value /// /// [`enum.Enum`]: https://docs.python.org/3/library/enum.html diff --git a/tests/test_native_enum.rs b/tests/test_native_enum.rs index cfda3a48eda..5cc81e8a83b 100644 --- a/tests/test_native_enum.rs +++ b/tests/test_native_enum.rs @@ -33,15 +33,15 @@ enum Bits { C = 4, } -#[py_native_enum(rename = "Colour")] -enum Colour { +#[py_native_enum(name = "Colour")] +enum RenamedColor { Red, Green, } #[py_native_enum] enum Named { - #[native_enum(rename = "FIRST")] + #[native_enum(name = "FIRST")] First, Second, } @@ -256,7 +256,7 @@ fn test_flag_enum() { #[test] fn test_rename_class() { Python::attach(|py| { - let cls = Colour::py_enum_class(py).unwrap(); + let cls = RenamedColor::py_enum_class(py).unwrap(); py_run!(py, cls, "assert cls.__name__ == 'Colour'"); }); }