diff --git a/guide/pyclass-parameters.md b/guide/pyclass-parameters.md index 5aefa4c69a0..c96082dd035 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. | +| `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 new file mode 100644 index 00000000000..f4b6dd5ae18 --- /dev/null +++ b/newsfragments/5421.added.md @@ -0,0 +1 @@ +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 6e7de98e318..9894c463628 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!(new); syn::custom_keyword!(signature); syn::custom_keyword!(str); syn::custom_keyword!(subclass); @@ -311,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; @@ -318,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 2d37d3cec7b..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,6 +86,7 @@ pub struct PyClassPyO3Options { pub rename_all: Option, pub sequence: Option, pub set_all: Option, + pub 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), + New(NewImplTypeAttribute), 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::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) { @@ -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::New(new) => set_option!(new), PyClassPyO3Option::Str(str) => set_option!(str), PyClassPyO3Option::Subclass(subclass) => set_option!(subclass), PyClassPyO3Option::Unsendable(unsendable) => set_option!(unsendable), @@ -468,6 +474,13 @@ fn impl_class( } } + let (default_new, default_new_slot) = pyclass_new_impl( + &args.options, + &syn::parse_quote!(#cls), + field_options.iter().map(|(f, _)| f), + ctx, + )?; + let mut default_methods = descriptors_to_items( cls, args.options.rename_all.as_ref(), @@ -496,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) @@ -514,6 +528,7 @@ fn impl_class( #default_richcmp #default_hash #default_str + #default_new #default_class_getitem } }) @@ -2226,6 +2241,80 @@ fn pyclass_hash( } } +fn pyclass_new_impl<'a>( + options: &PyClassPyO3Options, + ty: &syn::Type, + fields: impl Iterator, + ctx: &Ctx, +) -> Result<(Option, Option)> { + if options + .new + .as_ref() + .is_some_and(|o| matches!(o.value, NewImplTypeAttributeValue::FromFields)) + { + ensure_spanned!( + options.extends.is_none(), options.new.span() => "The `new=\"from_fields\"` option cannot be used with `extends`."; + ); + } + + match &options.new { + Some(opt) => { + 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() => + fn __pyo3_generated____new__( #( #field_idents : #field_types ),* ) -> Self { + Self { + #( #field_idents, )* + } + } + } + }; + + 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)), + } +} + fn pyclass_class_getitem( options: &PyClassPyO3Options, cls: &syn::Type, 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/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr index 8402451ec44..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`, `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`, `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)]