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..9ac43692b04 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 } }) @@ -1531,11 +1546,11 @@ fn generate_protocol_slot( ) -> syn::Result { let spec = FnSpec::parse( &mut method.sig, - &mut Vec::new(), + &mut method.attrs, PyFunctionOptions::default(), )?; #[cfg_attr(not(feature = "experimental-inspect"), allow(unused_mut))] - let mut def = slot.generate_type_slot(&syn::parse_quote!(#cls), &spec, name, ctx)?; + let mut def = slot.generate_type_slot(cls, &spec, name, ctx)?; #[cfg(feature = "experimental-inspect")] { // We generate introspection data @@ -2226,6 +2241,94 @@ 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`."; + ); + } + + let mut tuple_struct: bool = false; + + match &options.new { + Some(opt) => { + let mut field_idents = vec![]; + let mut field_types = vec![]; + for (idx, field) in fields.enumerate() { + tuple_struct = field.ident.is_none(); + + field_idents.push( + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("_{}", idx)), + ); + field_types.push(&field.ty); + } + + let mut new_impl = if tuple_struct { + parse_quote_spanned! { opt.span() => + #[new] + fn __pyo3_generated____new__( #( #field_idents : #field_types ),* ) -> Self { + Self ( + #( #field_idents, )* + ) + } + } + } else { + parse_quote_spanned! { opt.span() => + #[new] + 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..f87f6eeb6bb 100644 --- a/tests/test_class_attributes.rs +++ b/tests/test_class_attributes.rs @@ -235,6 +235,42 @@ 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" + ); + }); +} + +#[pyclass(new = "from_fields", get_all)] +struct Point2d(#[pyo3(name = "first")] f64, #[pyo3(name = "second")] f64); + +#[test] +fn new_impl_tuple_struct() { + 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(0.2, 0.3); assert inst.first == 0.2; assert inst.second == 0.3" + ); + }); +} + 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.rs b/tests/ui/invalid_pyclass_args.rs index 4c3c9fe02dc..15790a00efc 100644 --- a/tests/ui/invalid_pyclass_args.rs +++ b/tests/ui/invalid_pyclass_args.rs @@ -200,4 +200,9 @@ struct StructImplicitFromPyObjectDeprecated { b: String, } +#[pyclass(new = "from_fields")] +struct NonPythonField { + field: Box, +} + fn main() {} diff --git a/tests/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr index 8402451ec44..8cc65671abc 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)] @@ -181,6 +181,20 @@ help: consider annotating `StructFromPyObjectNoClone` with `#[derive(Clone)]` 192 | struct StructFromPyObjectNoClone { | +error[E0277]: `Box` cannot be used as a Python function argument + --> tests/ui/invalid_pyclass_args.rs:205:12 + | +205 | field: Box, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `PyFunctionArgument<'_, '_, '_, false>` is not implemented for `Box` + | + = note: implement `FromPyObject` to enable using `Box` as a function argument + = note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]` + = help: the following other types implement trait `PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>`: + `&'a pyo3::Bound<'py, T>` implements `PyFunctionArgument<'a, '_, 'py, false>` + `&'holder T` implements `PyFunctionArgument<'a, 'holder, '_, false>` + `&'holder mut T` implements `PyFunctionArgument<'a, 'holder, '_, false>` + `Option` implements `PyFunctionArgument<'a, 'holder, 'py, false>` + error[E0592]: duplicate definitions with name `__pymethod___richcmp____` --> tests/ui/invalid_pyclass_args.rs:37:1 | @@ -400,3 +414,84 @@ warning: use of deprecated associated constant `pyo3::impl_::deprecated::HasAuto | = note: `#[warn(deprecated)]` on by default = note: this warning originates in the attribute macro `pyclass` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `Box` cannot be used as a Python function argument + --> tests/ui/invalid_pyclass_args.rs:205:12 + | +205 | field: Box, + | ^^^ the trait `PyClass` is not implemented for `Box` + | + = note: implement `FromPyObject` to enable using `Box` as a function argument + = note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]` + = help: the following other types implement trait `PyClass`: + Coord + Coord2 + Coord3 + EqOptAndManualRichCmp + EqOptRequiresEq + HashOptAndManualHash + HashOptRequiresHash + NonPythonField + and $N others + = note: required for `Box` to implement `pyo3::FromPyObject<'_, '_>` + = note: required for `Box` to implement `PyFunctionArgument<'_, '_, '_, true>` +note: required by a bound in `extract_argument` + --> src/impl_/extract_argument.rs + | + | pub fn extract_argument<'a, 'holder, 'py, T, const IMPLEMENTS_FROMPYOBJECT: bool>( + | ---------------- required by a bound in this function +... + | T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument` + +error[E0277]: `Box` cannot be used as a Python function argument + --> tests/ui/invalid_pyclass_args.rs:205:12 + | +205 | field: Box, + | ^^^ the trait `ExtractPyClassWithClone` is not implemented for `Box` + | + = note: implement `FromPyObject` to enable using `Box` as a function argument + = note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]` + = help: the following other types implement trait `ExtractPyClassWithClone`: + Coord + Coord2 + Coord3 + EqOptAndManualRichCmp + EqOptRequiresEq + HashOptAndManualHash + HashOptRequiresHash + NonPythonField + and $N others + = note: required for `Box` to implement `pyo3::FromPyObject<'_, '_>` + = note: required for `Box` to implement `PyFunctionArgument<'_, '_, '_, true>` +note: required by a bound in `extract_argument` + --> src/impl_/extract_argument.rs + | + | pub fn extract_argument<'a, 'holder, 'py, T, const IMPLEMENTS_FROMPYOBJECT: bool>( + | ---------------- required by a bound in this function +... + | T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument` + +error[E0277]: the trait bound `dyn std::error::Error + Send + Sync: Clone` is not satisfied + --> tests/ui/invalid_pyclass_args.rs:205:12 + | +205 | field: Box, + | ^^^ the trait `Clone` is not implemented for `dyn std::error::Error + Send + Sync` + | + = help: the following other types implement trait `PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>`: + `&'a pyo3::Bound<'py, T>` implements `PyFunctionArgument<'a, '_, 'py, false>` + `&'holder T` implements `PyFunctionArgument<'a, 'holder, '_, false>` + `&'holder mut T` implements `PyFunctionArgument<'a, 'holder, '_, false>` + `Option` implements `PyFunctionArgument<'a, 'holder, 'py, false>` + = note: required for `Box` to implement `Clone` + = note: required for `Box` to implement `pyo3::FromPyObject<'_, '_>` + = note: required for `Box` to implement `PyFunctionArgument<'_, '_, '_, true>` +note: required by a bound in `extract_argument` + --> src/impl_/extract_argument.rs + | + | pub fn extract_argument<'a, 'holder, 'py, T, const IMPLEMENTS_FROMPYOBJECT: bool>( + | ---------------- required by a bound in this function +... + | T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument`