From 5d9eaa16d2f4e3a16c841eb6c7cfcc0f3d50c6dd Mon Sep 17 00:00:00 2001 From: red Date: Sun, 7 Sep 2025 15:31:57 +0200 Subject: [PATCH 01/10] implement auto_new on pyclasses --- guide/pyclass-parameters.md | 1 + pyo3-macros-backend/src/attributes.rs | 1 + pyo3-macros-backend/src/pyclass.rs | 40 +++++++++++++++++++-------- tests/test_multiple_pymethods.rs | 20 ++++++++++++++ 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/guide/pyclass-parameters.md b/guide/pyclass-parameters.md index 5aefa4c69a0..ddd2010c41e 100644 --- a/guide/pyclass-parameters.md +++ b/guide/pyclass-parameters.md @@ -22,6 +22,7 @@ | `rename_all = "renaming_rule"` | Applies renaming rules to every getters and setters of a struct, or every variants of an enum. Possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE". | | `sequence` | Inform PyO3 that this class is a [`Sequence`][params-sequence], and so leave its C-API mapping length slot empty. | | `set_all` | Generates setters for all fields of the pyclass. | +| `auto_new` | Generates a default `__new__` constructor, must be used with `set_all` | | `skip_from_py_object` | Prevents this PyClass from participating in the `FromPyObject: PyClass + Clone` blanket implementation. This allows a custom `FromPyObject` impl, even if `self` is `Clone`. | | `str` | Implements `__str__` using the `Display` implementation of the underlying Rust datatype or by passing an optional format string `str=""`. *Note: The optional format string is only allowed for structs. `name` and `rename_all` are incompatible with the optional format string. Additional details can be found in the discussion on this [PR](https://github.com/PyO3/pyo3/pull/4233).* | | `subclass` | Allows other Python classes and `#[pyclass]` to inherit from this class. Enums cannot be subclassed. | diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 6e7de98e318..69ecf8dc4b2 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -40,6 +40,7 @@ pub mod kw { syn::custom_keyword!(sequence); syn::custom_keyword!(set); syn::custom_keyword!(set_all); + syn::custom_keyword!(auto_new); syn::custom_keyword!(signature); syn::custom_keyword!(str); syn::custom_keyword!(subclass); diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 2d37d3cec7b..f85a0c648b6 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -2,38 +2,39 @@ use std::borrow::Cow; use std::fmt::Debug; use proc_macro2::{Ident, Span, TokenStream}; -use quote::{format_ident, quote, quote_spanned, ToTokens}; +use quote::{ToTokens, format_ident, quote, quote_spanned}; use syn::ext::IdentExt; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{parse_quote, parse_quote_spanned, spanned::Spanned, ImplItemFn, Result, Token}; +use syn::{ImplItemFn, Result, Token, parse_quote, parse_quote_spanned, spanned::Spanned}; +use crate::PyFunctionOptions; +use crate::PyFunctionOptions; use crate::attributes::kw::frozen; use crate::attributes::{ - self, kw, take_pyo3_options, CrateAttribute, ExtendsAttribute, FreelistAttribute, - ModuleAttribute, NameAttribute, NameLitStr, RenameAllAttribute, StrFormatterAttribute, + self, CrateAttribute, ExtendsAttribute, FreelistAttribute, ModuleAttribute, NameAttribute, + NameLitStr, RenameAllAttribute, StrFormatterAttribute, kw, take_pyo3_options, }; use crate::combine_errors::CombineErrors; #[cfg(feature = "experimental-inspect")] use crate::introspection::{ - class_introspection_code, function_introspection_code, introspection_id_const, PythonIdentifier, + PythonIdentifier, class_introspection_code, function_introspection_code, introspection_id_const, }; use crate::konst::{ConstAttributes, ConstSpec}; use crate::method::{FnArg, FnSpec, PyArg, RegularArg}; use crate::pyfunction::ConstructorAttribute; #[cfg(feature = "experimental-inspect")] use crate::pyfunction::FunctionSignature; -use crate::pyimpl::{gen_py_const, get_cfg_attributes, PyClassMethodsType}; +use crate::pyimpl::{PyClassMethodsType, gen_py_const, get_cfg_attributes}; #[cfg(feature = "experimental-inspect")] use crate::pymethod::field_python_name; use crate::pymethod::{ - impl_py_class_attribute, impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, - MethodAndSlotDef, PropertyType, SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __NEW__, - __REPR__, __RICHCMP__, __STR__, + __GETITEM__, __HASH__, __INT__, __LEN__, __NEW__, __REPR__, __RICHCMP__, __STR__, + MethodAndMethodDef, MethodAndSlotDef, PropertyType, SlotDef, impl_py_class_attribute, + impl_py_getter_def, impl_py_setter_def, }; use crate::pyversions::{is_abi3_before, is_py_before}; -use crate::utils::{self, apply_renaming_rule, Ctx, PythonDoc}; -use crate::PyFunctionOptions; +use crate::utils::{self, Ctx, PythonDoc, apply_renaming_rule}; /// If the class is derived from a Rust `struct` or `enum`. #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -85,6 +86,7 @@ pub struct PyClassPyO3Options { pub rename_all: Option, pub sequence: Option, pub set_all: Option, + pub auto_new: Option, pub str: Option, pub subclass: Option, pub unsendable: Option, @@ -112,6 +114,7 @@ pub enum PyClassPyO3Option { RenameAll(RenameAllAttribute), Sequence(kw::sequence), SetAll(kw::set_all), + AutoNew(kw::auto_new), Str(StrFormatterAttribute), Subclass(kw::subclass), Unsendable(kw::unsendable), @@ -158,6 +161,8 @@ impl Parse for PyClassPyO3Option { input.parse().map(PyClassPyO3Option::Sequence) } else if lookahead.peek(attributes::kw::set_all) { input.parse().map(PyClassPyO3Option::SetAll) + } else if lookahead.peek(attributes::kw::auto_new) { + input.parse().map(PyClassPyO3Option::AutoNew) } else if lookahead.peek(attributes::kw::str) { input.parse().map(PyClassPyO3Option::Str) } else if lookahead.peek(attributes::kw::subclass) { @@ -240,6 +245,7 @@ impl PyClassPyO3Options { PyClassPyO3Option::RenameAll(rename_all) => set_option!(rename_all), PyClassPyO3Option::Sequence(sequence) => set_option!(sequence), PyClassPyO3Option::SetAll(set_all) => set_option!(set_all), + PyClassPyO3Option::AutoNew(auto_new) => set_option!(auto_new), PyClassPyO3Option::Str(str) => set_option!(str), PyClassPyO3Option::Subclass(subclass) => set_option!(subclass), PyClassPyO3Option::Unsendable(unsendable) => set_option!(unsendable), @@ -468,6 +474,14 @@ fn impl_class( } } + let auto_new = pyclass_auto_new( + &args.options, + cls, + field_options.iter().map(|(f, _)| f), + methods_type, + ctx, + )?; + let mut default_methods = descriptors_to_items( cls, args.options.rename_all.as_ref(), @@ -508,6 +522,8 @@ fn impl_class( #py_class_impl + #auto_new + #[doc(hidden)] #[allow(non_snake_case)] impl #cls { @@ -2226,7 +2242,7 @@ fn pyclass_hash( } } -fn pyclass_class_getitem( +fn pyclass_class_geitem( options: &PyClassPyO3Options, cls: &syn::Type, ctx: &Ctx, diff --git a/tests/test_multiple_pymethods.rs b/tests/test_multiple_pymethods.rs index 1a788425113..236bda5fd21 100644 --- a/tests/test_multiple_pymethods.rs +++ b/tests/test_multiple_pymethods.rs @@ -73,3 +73,23 @@ fn test_class_with_multiple_pymethods() { py_assert!(py, cls, "cls.CLASS_ATTRIBUTE == 'CLASS_ATTRIBUTE'"); }) } + +#[pyclass(get_all, set_all, auto_new)] +struct AutoNewCls { + a: i32, + b: String, + c: Option, +} + +#[test] +fn auto_new() { + Python::attach(|py| { + // python should be able to do AutoNewCls(1, "two", 3.0) + let cls = py.get_type::(); + pyo3::py_run!( + py, + cls, + "inst = cls(1, 'two', 3.0); assert inst.a == 1; assert inst.b == 'two'; assert inst.c == 3.0" + ); + }); +} From a0edb2121ab4ecfb6077f1986382e345d54c27ab Mon Sep 17 00:00:00 2001 From: red Date: Mon, 8 Sep 2025 15:47:32 +0200 Subject: [PATCH 02/10] fix double import --- pyo3-macros-backend/src/pyclass.rs | 55 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index f85a0c648b6..79368b83c02 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -8,7 +8,6 @@ use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{ImplItemFn, Result, Token, parse_quote, parse_quote_spanned, spanned::Spanned}; -use crate::PyFunctionOptions; use crate::PyFunctionOptions; use crate::attributes::kw::frozen; use crate::attributes::{ @@ -2242,7 +2241,59 @@ fn pyclass_hash( } } -fn pyclass_class_geitem( +fn pyclass_auto_new<'a>( + options: &PyClassPyO3Options, + cls: &syn::Ident, + fields: impl Iterator, + methods_type: PyClassMethodsType, + ctx: &Ctx, +) -> Result> { + if options.auto_new.is_some() { + ensure_spanned!( + options.set_all.is_some(), options.hash.span() => "The `auto_new` option requires the `set_all` option."; + ); + } + match options.auto_new { + Some(opt) => { + if matches!(methods_type, PyClassMethodsType::Specialization) { + bail_spanned!(opt.span() => "`auto_new` requires the `multiple-pymethods` feature."); + } + + let autonew_impl = { + let Ctx { pyo3_path, .. } = ctx; + let mut field_idents = vec![]; + let mut field_types = vec![]; + for (idx, field) in fields.enumerate() { + field_idents.push( + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("_{}", idx)), + ); + field_types.push(&field.ty); + } + + parse_quote_spanned! { opt.span() => + #[#pyo3_path::pymethods] + impl #cls { + #[new] + fn _pyo3_generated_new( #( #field_idents : #field_types ),* ) -> Self { + Self { + #( #field_idents, )* + } + } + } + + } + }; + + Ok(Some(autonew_impl)) + } + None => Ok(None), + } +} + +fn pyclass_class_getitem( options: &PyClassPyO3Options, cls: &syn::Type, ctx: &Ctx, From 0ed977c703f125da2f766734c99079fc266c77fd Mon Sep 17 00:00:00 2001 From: red Date: Mon, 8 Sep 2025 18:22:44 +0200 Subject: [PATCH 03/10] add changelog fragment --- newsfragments/5421.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/5421.added.md diff --git a/newsfragments/5421.added.md b/newsfragments/5421.added.md new file mode 100644 index 00000000000..fe71621fc9f --- /dev/null +++ b/newsfragments/5421.added.md @@ -0,0 +1 @@ +Implement `auto_new` attribute for `#[pyclass]` \ No newline at end of file From ab2ca3192d83ab7399b13510b9cac861e2ed0457 Mon Sep 17 00:00:00 2001 From: red Date: Mon, 8 Sep 2025 18:22:50 +0200 Subject: [PATCH 04/10] fix import formatting --- pyo3-macros-backend/src/pyclass.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 79368b83c02..39d5a1b2c1a 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -8,7 +8,6 @@ use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{ImplItemFn, Result, Token, parse_quote, parse_quote_spanned, spanned::Spanned}; -use crate::PyFunctionOptions; use crate::attributes::kw::frozen; use crate::attributes::{ self, CrateAttribute, ExtendsAttribute, FreelistAttribute, ModuleAttribute, NameAttribute, @@ -34,6 +33,7 @@ use crate::pymethod::{ }; use crate::pyversions::{is_abi3_before, is_py_before}; use crate::utils::{self, Ctx, PythonDoc, apply_renaming_rule}; +use crate::PyFunctionOptions; /// If the class is derived from a Rust `struct` or `enum`. #[derive(Copy, Clone, Debug, PartialEq, Eq)] From b6bcb50048d65fd33d243a27e23f5fdc7454eb4e Mon Sep 17 00:00:00 2001 From: red Date: Tue, 21 Oct 2025 18:13:12 +0200 Subject: [PATCH 05/10] auto_new: unrestrict set_all and restrict extends --- pyo3-macros-backend/src/pyclass.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 39d5a1b2c1a..9cb3e2ef12d 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -2250,9 +2250,10 @@ fn pyclass_auto_new<'a>( ) -> Result> { if options.auto_new.is_some() { ensure_spanned!( - options.set_all.is_some(), options.hash.span() => "The `auto_new` option requires the `set_all` option."; - ); + options.extends.is_none(), options.hash.span() => "The `auto_new` option cannot be used with `extends`."; + ); } + match options.auto_new { Some(opt) => { if matches!(methods_type, PyClassMethodsType::Specialization) { From 3f675eed93a53152a89cf053c00a9e4372a8bd33 Mon Sep 17 00:00:00 2001 From: red Date: Tue, 21 Oct 2025 18:23:51 +0200 Subject: [PATCH 06/10] update ui test --- tests/ui/invalid_pyclass_args.stderr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr index 8402451ec44..7b4178aa32b 100644 --- a/tests/ui/invalid_pyclass_args.stderr +++ b/tests/ui/invalid_pyclass_args.stderr @@ -1,4 +1,4 @@ -error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` +error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `auto_new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` --> tests/ui/invalid_pyclass_args.rs:4:11 | 4 | #[pyclass(extend=pyo3::types::PyDict)] @@ -46,7 +46,7 @@ error: expected string literal 25 | #[pyclass(module = my_module)] | ^^^^^^^^^ -error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` +error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `auto_new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` --> tests/ui/invalid_pyclass_args.rs:28:11 | 28 | #[pyclass(weakrev)] From 036004f486b360fb44843dd5a8a08526c547ec2d Mon Sep 17 00:00:00 2001 From: red Date: Tue, 21 Oct 2025 18:25:29 +0200 Subject: [PATCH 07/10] cargo fmt --- pyo3-macros-backend/src/pyclass.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 9cb3e2ef12d..6f4ee98757d 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -2251,7 +2251,7 @@ fn pyclass_auto_new<'a>( if options.auto_new.is_some() { ensure_spanned!( options.extends.is_none(), options.hash.span() => "The `auto_new` option cannot be used with `extends`."; - ); + ); } match options.auto_new { From 9f652c0af3f5ec7cb8289ebff5ade269d82d2361 Mon Sep 17 00:00:00 2001 From: red Date: Tue, 18 Nov 2025 23:34:08 +0100 Subject: [PATCH 08/10] formatting --- pyo3-macros-backend/src/pyclass.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 6f4ee98757d..824a8d1a40a 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -2,37 +2,37 @@ use std::borrow::Cow; use std::fmt::Debug; use proc_macro2::{Ident, Span, TokenStream}; -use quote::{ToTokens, format_ident, quote, quote_spanned}; +use quote::{format_ident, quote, quote_spanned, ToTokens}; use syn::ext::IdentExt; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{ImplItemFn, Result, Token, parse_quote, parse_quote_spanned, spanned::Spanned}; +use syn::{parse_quote, parse_quote_spanned, spanned::Spanned, ImplItemFn, Result, Token}; use crate::attributes::kw::frozen; use crate::attributes::{ - self, CrateAttribute, ExtendsAttribute, FreelistAttribute, ModuleAttribute, NameAttribute, - NameLitStr, RenameAllAttribute, StrFormatterAttribute, kw, take_pyo3_options, + self, kw, take_pyo3_options, CrateAttribute, ExtendsAttribute, FreelistAttribute, + ModuleAttribute, NameAttribute, NameLitStr, RenameAllAttribute, StrFormatterAttribute, }; use crate::combine_errors::CombineErrors; #[cfg(feature = "experimental-inspect")] use crate::introspection::{ - PythonIdentifier, class_introspection_code, function_introspection_code, introspection_id_const, + class_introspection_code, function_introspection_code, introspection_id_const, PythonIdentifier, }; use crate::konst::{ConstAttributes, ConstSpec}; use crate::method::{FnArg, FnSpec, PyArg, RegularArg}; use crate::pyfunction::ConstructorAttribute; #[cfg(feature = "experimental-inspect")] use crate::pyfunction::FunctionSignature; -use crate::pyimpl::{PyClassMethodsType, gen_py_const, get_cfg_attributes}; +use crate::pyimpl::{gen_py_const, get_cfg_attributes, PyClassMethodsType}; #[cfg(feature = "experimental-inspect")] use crate::pymethod::field_python_name; use crate::pymethod::{ - __GETITEM__, __HASH__, __INT__, __LEN__, __NEW__, __REPR__, __RICHCMP__, __STR__, - MethodAndMethodDef, MethodAndSlotDef, PropertyType, SlotDef, impl_py_class_attribute, - impl_py_getter_def, impl_py_setter_def, + impl_py_class_attribute, impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, + MethodAndSlotDef, PropertyType, SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __NEW__, + __REPR__, __RICHCMP__, __STR__, }; use crate::pyversions::{is_abi3_before, is_py_before}; -use crate::utils::{self, Ctx, PythonDoc, apply_renaming_rule}; +use crate::utils::{self, apply_renaming_rule, Ctx, PythonDoc}; use crate::PyFunctionOptions; /// If the class is derived from a Rust `struct` or `enum`. From 10175ac130191a4561d41713e49228a72eeb7fb4 Mon Sep 17 00:00:00 2001 From: red Date: Wed, 19 Nov 2025 00:33:53 +0100 Subject: [PATCH 09/10] update new impl macro --- guide/pyclass-parameters.md | 2 +- newsfragments/5421.added.md | 2 +- pyo3-macros-backend/src/attributes.rs | 30 ++++++- pyo3-macros-backend/src/pyclass.rs | 109 +++++++++++++++----------- tests/test_class_attributes.rs | 20 +++++ tests/test_multiple_pymethods.rs | 20 ----- 6 files changed, 116 insertions(+), 67 deletions(-) diff --git a/guide/pyclass-parameters.md b/guide/pyclass-parameters.md index ddd2010c41e..c96082dd035 100644 --- a/guide/pyclass-parameters.md +++ b/guide/pyclass-parameters.md @@ -22,7 +22,7 @@ | `rename_all = "renaming_rule"` | Applies renaming rules to every getters and setters of a struct, or every variants of an enum. Possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE". | | `sequence` | Inform PyO3 that this class is a [`Sequence`][params-sequence], and so leave its C-API mapping length slot empty. | | `set_all` | Generates setters for all fields of the pyclass. | -| `auto_new` | Generates a default `__new__` constructor, must be used with `set_all` | +| `new = "from_fields"` | Generates a default `__new__` constructor with all fields as parameters in the `new()` method. | | `skip_from_py_object` | Prevents this PyClass from participating in the `FromPyObject: PyClass + Clone` blanket implementation. This allows a custom `FromPyObject` impl, even if `self` is `Clone`. | | `str` | Implements `__str__` using the `Display` implementation of the underlying Rust datatype or by passing an optional format string `str=""`. *Note: The optional format string is only allowed for structs. `name` and `rename_all` are incompatible with the optional format string. Additional details can be found in the discussion on this [PR](https://github.com/PyO3/pyo3/pull/4233).* | | `subclass` | Allows other Python classes and `#[pyclass]` to inherit from this class. Enums cannot be subclassed. | diff --git a/newsfragments/5421.added.md b/newsfragments/5421.added.md index fe71621fc9f..f4b6dd5ae18 100644 --- a/newsfragments/5421.added.md +++ b/newsfragments/5421.added.md @@ -1 +1 @@ -Implement `auto_new` attribute for `#[pyclass]` \ No newline at end of file +Implement `new = "from_fields"` attribute for `#[pyclass]` \ No newline at end of file diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 69ecf8dc4b2..9894c463628 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -40,7 +40,7 @@ pub mod kw { syn::custom_keyword!(sequence); syn::custom_keyword!(set); syn::custom_keyword!(set_all); - syn::custom_keyword!(auto_new); + syn::custom_keyword!(new); syn::custom_keyword!(signature); syn::custom_keyword!(str); syn::custom_keyword!(subclass); @@ -312,6 +312,33 @@ impl ToTokens for TextSignatureAttributeValue { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NewImplTypeAttributeValue { + FromFields, + // Future variant for 'default' should go here +} + +impl Parse for NewImplTypeAttributeValue { + fn parse(input: ParseStream<'_>) -> Result { + let string_literal: LitStr = input.parse()?; + if string_literal.value().as_str() == "from_fields" { + Ok(NewImplTypeAttributeValue::FromFields) + } else { + bail_spanned!(string_literal.span() => "expected \"from_fields\"") + } + } +} + +impl ToTokens for NewImplTypeAttributeValue { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + NewImplTypeAttributeValue::FromFields => { + tokens.extend(quote! { "from_fields" }); + } + } + } +} + pub type ExtendsAttribute = KeywordAttribute; pub type FreelistAttribute = KeywordAttribute>; pub type ModuleAttribute = KeywordAttribute; @@ -319,6 +346,7 @@ pub type NameAttribute = KeywordAttribute; pub type RenameAllAttribute = KeywordAttribute; pub type StrFormatterAttribute = OptionalKeywordAttribute; pub type TextSignatureAttribute = KeywordAttribute; +pub type NewImplTypeAttribute = KeywordAttribute; pub type SubmoduleAttribute = kw::submodule; pub type GILUsedAttribute = KeywordAttribute; diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 824a8d1a40a..b1e7a0cd02a 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -11,7 +11,8 @@ use syn::{parse_quote, parse_quote_spanned, spanned::Spanned, ImplItemFn, Result use crate::attributes::kw::frozen; use crate::attributes::{ self, kw, take_pyo3_options, CrateAttribute, ExtendsAttribute, FreelistAttribute, - ModuleAttribute, NameAttribute, NameLitStr, RenameAllAttribute, StrFormatterAttribute, + ModuleAttribute, NameAttribute, NameLitStr, NewImplTypeAttribute, NewImplTypeAttributeValue, + RenameAllAttribute, StrFormatterAttribute, }; use crate::combine_errors::CombineErrors; #[cfg(feature = "experimental-inspect")] @@ -85,7 +86,7 @@ pub struct PyClassPyO3Options { pub rename_all: Option, pub sequence: Option, pub set_all: Option, - pub auto_new: Option, + pub new: Option, pub str: Option, pub subclass: Option, pub unsendable: Option, @@ -113,7 +114,7 @@ pub enum PyClassPyO3Option { RenameAll(RenameAllAttribute), Sequence(kw::sequence), SetAll(kw::set_all), - AutoNew(kw::auto_new), + New(NewImplTypeAttribute), Str(StrFormatterAttribute), Subclass(kw::subclass), Unsendable(kw::unsendable), @@ -160,8 +161,8 @@ impl Parse for PyClassPyO3Option { input.parse().map(PyClassPyO3Option::Sequence) } else if lookahead.peek(attributes::kw::set_all) { input.parse().map(PyClassPyO3Option::SetAll) - } else if lookahead.peek(attributes::kw::auto_new) { - input.parse().map(PyClassPyO3Option::AutoNew) + } else if lookahead.peek(attributes::kw::new) { + input.parse().map(PyClassPyO3Option::New) } else if lookahead.peek(attributes::kw::str) { input.parse().map(PyClassPyO3Option::Str) } else if lookahead.peek(attributes::kw::subclass) { @@ -244,7 +245,7 @@ impl PyClassPyO3Options { PyClassPyO3Option::RenameAll(rename_all) => set_option!(rename_all), PyClassPyO3Option::Sequence(sequence) => set_option!(sequence), PyClassPyO3Option::SetAll(set_all) => set_option!(set_all), - PyClassPyO3Option::AutoNew(auto_new) => set_option!(auto_new), + PyClassPyO3Option::New(new) => set_option!(new), PyClassPyO3Option::Str(str) => set_option!(str), PyClassPyO3Option::Subclass(subclass) => set_option!(subclass), PyClassPyO3Option::Unsendable(unsendable) => set_option!(unsendable), @@ -473,11 +474,10 @@ fn impl_class( } } - let auto_new = pyclass_auto_new( + let (default_new, default_new_slot) = pyclass_new_impl( &args.options, - cls, + &syn::parse_quote!(#cls), field_options.iter().map(|(f, _)| f), - methods_type, ctx, )?; @@ -509,6 +509,7 @@ fn impl_class( slots.extend(default_richcmp_slot); slots.extend(default_hash_slot); slots.extend(default_str_slot); + slots.extend(default_new_slot); let py_class_impl = PyClassImplsBuilder::new(cls, args, methods_type, default_methods, slots) .doc(doc) @@ -521,14 +522,13 @@ fn impl_class( #py_class_impl - #auto_new - #[doc(hidden)] #[allow(non_snake_case)] impl #cls { #default_richcmp #default_hash #default_str + #default_new #default_class_getitem } }) @@ -2241,56 +2241,77 @@ fn pyclass_hash( } } -fn pyclass_auto_new<'a>( +fn pyclass_new_impl<'a>( options: &PyClassPyO3Options, - cls: &syn::Ident, + ty: &syn::Type, fields: impl Iterator, - methods_type: PyClassMethodsType, ctx: &Ctx, -) -> Result> { - if options.auto_new.is_some() { +) -> Result<(Option, Option)> { + if options + .new + .as_ref() + .is_some_and(|o| matches!(o.value, NewImplTypeAttributeValue::FromFields)) + { ensure_spanned!( - options.extends.is_none(), options.hash.span() => "The `auto_new` option cannot be used with `extends`."; + options.extends.is_none(), options.new.span() => "The `new=\"from_fields\"` option cannot be used with `extends`."; ); } - match options.auto_new { + match &options.new { Some(opt) => { - if matches!(methods_type, PyClassMethodsType::Specialization) { - bail_spanned!(opt.span() => "`auto_new` requires the `multiple-pymethods` feature."); + let mut field_idents = vec![]; + let mut field_types = vec![]; + for (idx, field) in fields.enumerate() { + field_idents.push( + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("_{}", idx)), + ); + field_types.push(&field.ty); } - let autonew_impl = { - let Ctx { pyo3_path, .. } = ctx; - let mut field_idents = vec![]; - let mut field_types = vec![]; - for (idx, field) in fields.enumerate() { - field_idents.push( - field - .ident - .clone() - .unwrap_or_else(|| format_ident!("_{}", idx)), - ); - field_types.push(&field.ty); - } - + let mut new_impl = { parse_quote_spanned! { opt.span() => - #[#pyo3_path::pymethods] - impl #cls { - #[new] - fn _pyo3_generated_new( #( #field_idents : #field_types ),* ) -> Self { - Self { - #( #field_idents, )* - } + fn __pyo3_generated____new__( #( #field_idents : #field_types ),* ) -> Self { + Self { + #( #field_idents, )* } } - } }; - Ok(Some(autonew_impl)) + let new_slot = generate_protocol_slot( + ty, + &mut new_impl, + &__NEW__, + "__new__", + #[cfg(feature = "experimental-inspect")] + FunctionIntrospectionData { + names: &["__new__"], + arguments: field_idents + .iter() + .zip(field_types.iter()) + .map(|(ident, ty)| { + FnArg::Regular(RegularArg { + name: Cow::Owned(ident.clone()), + ty, + from_py_with: None, + default_value: None, + option_wrapped_type: None, + annotation: None, + }) + }) + .collect(), + returns: ty.clone(), + }, + ctx, + ) + .unwrap(); + + Ok((Some(new_impl), Some(new_slot))) } - None => Ok(None), + None => Ok((None, None)), } } diff --git a/tests/test_class_attributes.rs b/tests/test_class_attributes.rs index f706b414ff3..7dbad8bcfc8 100644 --- a/tests/test_class_attributes.rs +++ b/tests/test_class_attributes.rs @@ -235,6 +235,26 @@ fn test_renaming_all_struct_fields() { }); } +#[pyclass(get_all, set_all, new = "from_fields")] +struct AutoNewCls { + a: i32, + b: String, + c: Option, +} + +#[test] +fn new_impl() { + Python::attach(|py| { + // python should be able to do AutoNewCls(1, "two", 3.0) + let cls = py.get_type::(); + pyo3::py_run!( + py, + cls, + "inst = cls(1, 'two', 3.0); assert inst.a == 1; assert inst.b == 'two'; assert inst.c == 3.0" + ); + }); +} + macro_rules! test_case { ($struct_name: ident, $rule: literal, $field_name: ident, $renamed_field_name: literal, $test_name: ident) => { #[pyclass(get_all, set_all, rename_all = $rule)] diff --git a/tests/test_multiple_pymethods.rs b/tests/test_multiple_pymethods.rs index 236bda5fd21..1a788425113 100644 --- a/tests/test_multiple_pymethods.rs +++ b/tests/test_multiple_pymethods.rs @@ -73,23 +73,3 @@ fn test_class_with_multiple_pymethods() { py_assert!(py, cls, "cls.CLASS_ATTRIBUTE == 'CLASS_ATTRIBUTE'"); }) } - -#[pyclass(get_all, set_all, auto_new)] -struct AutoNewCls { - a: i32, - b: String, - c: Option, -} - -#[test] -fn auto_new() { - Python::attach(|py| { - // python should be able to do AutoNewCls(1, "two", 3.0) - let cls = py.get_type::(); - pyo3::py_run!( - py, - cls, - "inst = cls(1, 'two', 3.0); assert inst.a == 1; assert inst.b == 'two'; assert inst.c == 3.0" - ); - }); -} From eddcad5e5f6b5b16c85c4a0b33ddee833d41cdfe Mon Sep 17 00:00:00 2001 From: red Date: Wed, 19 Nov 2025 00:40:06 +0100 Subject: [PATCH 10/10] update ui test --- tests/ui/invalid_pyclass_args.stderr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr index 7b4178aa32b..55d783e16a5 100644 --- a/tests/ui/invalid_pyclass_args.stderr +++ b/tests/ui/invalid_pyclass_args.stderr @@ -1,4 +1,4 @@ -error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `auto_new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` +error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` --> tests/ui/invalid_pyclass_args.rs:4:11 | 4 | #[pyclass(extend=pyo3::types::PyDict)] @@ -46,7 +46,7 @@ error: expected string literal 25 | #[pyclass(module = my_module)] | ^^^^^^^^^ -error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `auto_new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` +error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `new`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `from_py_object`, `skip_from_py_object` --> tests/ui/invalid_pyclass_args.rs:28:11 | 28 | #[pyclass(weakrev)]