Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions guide/pyclass-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<format string>"`. *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. |
Expand Down
1 change: 1 addition & 0 deletions newsfragments/5421.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement `new = "from_fields"` attribute for `#[pyclass]`
29 changes: 29 additions & 0 deletions pyo3-macros-backend/src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -311,13 +312,41 @@ impl ToTokens for TextSignatureAttributeValue {
}
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NewImplTypeAttributeValue {
FromFields,
// Future variant for 'default' should go here
}
Comment on lines +315 to +319
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have a quick think about the interaction of #[pyo3(new = "default")] and the other future extensions I suggested in #5421 (review)

  • Should new = "default" accept any arguments? The easy answer is no. But what if users want to have a constructor which is the equivalent of MyStruct { x, y, ...Default::default() }, i.e. use the struct-level default except for some specific fields?
  • Should fields accept #[pyo3(new = <value>)] to remove them as arguments from the generated constructor and always set them to <value> (similar to dataclasses.field(init = False))? This would appear to have very strong overlap with #[pyo3(new = "default")]on the struct, presumablyfor the field would come from theDefault` implementation.
  • Once we have both of these, what is the difference between "from_fields" with a bunch of #[pyo3(default = <value>)] annotations on fields?

... it feels to me like the general design would be that new = "from_fields" would not require a Default implementation, and would allow users to take fields out of the constructor and instead give them default values via #[pyo3(default = <value>)].

new = "default" would be the opposite; it would require a Default implementation and would require users to opt-in to add fields to the constructor to allow callers to override the value set by the Default implementation. It feels like #[pyo3(new = true)] might be the right annotation for this, but I can't decide. That can in theory be a future extension for new = "default" so maybe it's a long way off.

We don't need to solve this now, but I'd at least like to make sure that having "from_fields" as implemented here doesn't accidentally close off the design space. We can write this all into a follow-up issue after merge.

Copy link
Author

@RedKinda RedKinda Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using new = "from_fields", i think field-level defaults should make that field a keyword argument in __new__ and give it the configured default. Example:

#[pyclass(new = "from_fields")]
struct Foo {
    a: u64
    #[pyo3(default=5)]
    b: u64
}

would be the equivalent of

class Foo:
    def __new__(a: int, b: int = 5) -> Self:
        ...

Using new = "default" could then simply always produce an empty __new__.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if a user didn't want b to be user-providable at all? Maybe a #[pyo3(new_arg = false)] on the field e.g. maybe this:

#[pyclass(new = "from_fields")]
struct Foo {
    a: u64
    #[pyo3(new_arg = false, default=5)]
    b: u64
}

would produce Python API

class Foo:
    def __new__(a: int) -> Self:
        ...

and the Rust implementation would be equivalent to

#[pymethods]
impl Foo {
    #[new]
    fn new(a: u64) -> Self {
        Self {
            a,
            b: 5  // if `new_arg = false` was not set, then `b` would still be set here but just with default of 5
        }
    }

Using new = "default" could then simply always produce an empty __new__.

Seems reasonable, and users could add arguments with #[pyo3(new_arg = true)]? e.g.

#[pyclass(new = "default")]
struct Foo {
    a: u64
    #[pyo3(new_arg = true, default=5)]
    b: u64
}

would produce Python API

class Foo:
    def __new__(a: int, b: int=5) -> Self:
        ...

and a Rust implemementation equivalent to

#[pymethods]
impl Foo {
    #[new]
    #[pyo3(signature = (b = 5))]
    fn new(b: u64) -> Self {
        Self {
            b,
            ..Self::default()
        }
    }


impl Parse for NewImplTypeAttributeValue {
fn parse(input: ParseStream<'_>) -> Result<Self> {
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<kw::extends, Path>;
pub type FreelistAttribute = KeywordAttribute<kw::freelist, Box<Expr>>;
pub type ModuleAttribute = KeywordAttribute<kw::module, LitStr>;
pub type NameAttribute = KeywordAttribute<kw::name, NameLitStr>;
pub type RenameAllAttribute = KeywordAttribute<kw::rename_all, RenamingRuleLitStr>;
pub type StrFormatterAttribute = OptionalKeywordAttribute<kw::str, StringFormatter>;
pub type TextSignatureAttribute = KeywordAttribute<kw::text_signature, TextSignatureAttributeValue>;
pub type NewImplTypeAttribute = KeywordAttribute<kw::new, NewImplTypeAttributeValue>;
pub type SubmoduleAttribute = kw::submodule;
pub type GILUsedAttribute = KeywordAttribute<kw::gil_used, LitBool>;

Expand Down
109 changes: 106 additions & 3 deletions pyo3-macros-backend/src/pyclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -87,6 +88,7 @@ pub struct PyClassPyO3Options {
pub rename_all: Option<RenameAllAttribute>,
pub sequence: Option<kw::sequence>,
pub set_all: Option<kw::set_all>,
pub new: Option<NewImplTypeAttribute>,
pub str: Option<StrFormatterAttribute>,
pub subclass: Option<kw::subclass>,
pub unsendable: Option<kw::unsendable>,
Expand Down Expand Up @@ -114,6 +116,7 @@ pub enum PyClassPyO3Option {
RenameAll(RenameAllAttribute),
Sequence(kw::sequence),
SetAll(kw::set_all),
New(NewImplTypeAttribute),
Str(StrFormatterAttribute),
Subclass(kw::subclass),
Unsendable(kw::unsendable),
Expand Down Expand Up @@ -160,6 +163,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) {
Expand Down Expand Up @@ -242,6 +247,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),
Expand Down Expand Up @@ -487,6 +493,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(),
Expand Down Expand Up @@ -515,6 +528,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)
Expand All @@ -533,6 +547,7 @@ fn impl_class(
#default_richcmp
#default_hash
#default_str
#default_new
#default_class_getitem
}
})
Expand Down Expand Up @@ -1593,11 +1608,11 @@ fn generate_protocol_slot(
) -> syn::Result<MethodAndSlotDef> {
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
Expand Down Expand Up @@ -2301,6 +2316,94 @@ fn pyclass_hash(
}
}

fn pyclass_new_impl<'a>(
options: &PyClassPyO3Options,
ty: &syn::Type,
fields: impl Iterator<Item = &'a &'a syn::Field>,
ctx: &Ctx,
) -> Result<(Option<ImplItemFn>, Option<MethodAndSlotDef>)> {
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,
Expand Down
36 changes: 36 additions & 0 deletions tests/test_class_attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
}

#[test]
fn new_impl() {
Python::attach(|py| {
// python should be able to do AutoNewCls(1, "two", 3.0)
let cls = py.get_type::<AutoNewCls>();
pyo3::py_run!(
py,
cls,
"inst = cls(1, 'two', 3.0); assert inst.a == 1; assert inst.b == 'two'; assert inst.c == 3.0"
);
});
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to add a test with a tuple struct, e.g.

struct Point2d(f64, f64);

... I would think the generated constructor would allow only positional inputs, as there are no names for the fields.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(It might be good enough for this to gracefully fail with a "not yet supported" message as far as this PR is concerned.)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless i am missing something i added support for tuple structs here 4881636

#[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::<Point2d>();
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)]
Expand Down
2 changes: 2 additions & 0 deletions tests/test_compile_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ fn test_compile_errors() {
#[cfg(not(feature = "experimental-inspect"))]
t.compile_fail("tests/ui/invalid_property_args.rs");
t.compile_fail("tests/ui/invalid_proto_pymethods.rs");
#[cfg(not(all(Py_LIMITED_API, not(Py_3_10))))] // to avoid PyFunctionArgument for &str
t.compile_fail("tests/ui/invalid_pyclass_args.rs");
t.compile_fail("tests/ui/invalid_pyclass_doc.rs");
t.compile_fail("tests/ui/invalid_pyclass_enum.rs");
Expand All @@ -19,6 +20,7 @@ fn test_compile_errors() {
#[cfg(Py_3_9)]
t.compile_fail("tests/ui/pyclass_generic_enum.rs");
#[cfg(not(feature = "experimental-inspect"))]
#[cfg(not(all(Py_LIMITED_API, not(Py_3_10))))] // to avoid PyFunctionArgument for &str
t.compile_fail("tests/ui/invalid_pyfunction_argument.rs");
t.compile_fail("tests/ui/invalid_pyfunction_definition.rs");
t.compile_fail("tests/ui/invalid_pyfunction_signatures.rs");
Expand Down
5 changes: 5 additions & 0 deletions tests/ui/invalid_pyclass_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,9 @@ struct StructImplicitFromPyObjectDeprecated {
b: String,
}

#[pyclass(new = "from_fields")]
struct NonPythonField {
field: Box<dyn std::error::Error + Send + Sync>,
}

fn main() {}
Loading
Loading