Skip to content
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 @@ -85,6 +86,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 @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand All @@ -514,6 +528,7 @@ fn impl_class(
#default_richcmp
#default_hash
#default_str
#default_new
#default_class_getitem
}
})
Expand Down Expand Up @@ -1531,11 +1546,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 @@ -2226,6 +2241,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
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