Skip to content

Commit

Permalink
added #[pyo3(rename_all = "...")] container attribute for `#[derive…
Browse files Browse the repository at this point in the history
…(FromPyObject)]`
  • Loading branch information
Icxolu committed Feb 27, 2025
1 parent 241080a commit 0dd3887
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 8 deletions.
4 changes: 4 additions & 0 deletions guide/src/conversions/traits.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,10 @@ If the input is neither a string nor an integer, the error message will be:
- changes the name of the failed variant in the generated error message in case of failure.
- e.g. `pyo3("int")` reports the variant's type as `int`.
- only supported for enum variants
- `pyo3(rename_all = "...")`
- renames all attributes/item keys according to the specified renaming rule
- Possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE".
- fields with an explicit renaming via `attribute(...)`/`item(...)` are not affected

#### `#[derive(FromPyObject)]` Field Attributes
- `pyo3(attribute)`, `pyo3(attribute("name"))`
Expand Down
1 change: 1 addition & 0 deletions newsfragments/4941.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
add `#[pyo3(rename_all = "...")]` for `#[derive(FromPyObject)]`
58 changes: 51 additions & 7 deletions pyo3-macros-backend/src/frompyobject.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::attributes::{
self, get_pyo3_options, CrateAttribute, DefaultAttribute, FromPyWithAttribute,
RenameAllAttribute, RenamingRule,
};
use crate::utils::Ctx;
use crate::utils::{self, Ctx};
use proc_macro2::TokenStream;
use quote::{format_ident, quote, quote_spanned, ToTokens};
use syn::{
Expand All @@ -25,7 +26,7 @@ impl<'a> Enum<'a> {
///
/// `data_enum` is the `syn` representation of the input enum, `ident` is the
/// `Identifier` of the enum.
fn new(data_enum: &'a DataEnum, ident: &'a Ident) -> Result<Self> {
fn new(data_enum: &'a DataEnum, ident: &'a Ident, options: ContainerOptions) -> Result<Self> {
ensure_spanned!(
!data_enum.variants.is_empty(),
ident.span() => "cannot derive FromPyObject for empty enum"
Expand All @@ -34,9 +35,21 @@ impl<'a> Enum<'a> {
.variants
.iter()
.map(|variant| {
let attrs = ContainerOptions::from_attrs(&variant.attrs)?;
let mut variant_options = ContainerOptions::from_attrs(&variant.attrs)?;
if let Some(rename_all) = &options.rename_all {
ensure_spanned!(
variant_options.rename_all.is_none(),
variant_options.rename_all.span() => "Useless variant `rename_all` - enum is already annotated with `rename_all"
);
variant_options.rename_all = Some(rename_all.clone());

}
let var_ident = &variant.ident;
Container::new(&variant.fields, parse_quote!(#ident::#var_ident), attrs)
Container::new(
&variant.fields,
parse_quote!(#ident::#var_ident),
variant_options,
)
})
.collect::<Result<Vec<_>>>()?;

Expand Down Expand Up @@ -129,6 +142,7 @@ struct Container<'a> {
path: syn::Path,
ty: ContainerType<'a>,
err_name: String,
rename_rule: Option<RenamingRule>,
}

impl<'a> Container<'a> {
Expand All @@ -138,6 +152,10 @@ impl<'a> Container<'a> {
fn new(fields: &'a Fields, path: syn::Path, options: ContainerOptions) -> Result<Self> {
let style = match fields {
Fields::Unnamed(unnamed) if !unnamed.unnamed.is_empty() => {
ensure_spanned!(
options.rename_all.is_none(),
options.rename_all.span() => "`rename_all` is useless on tuple structs and variants."
);
let mut tuple_fields = unnamed
.unnamed
.iter()
Expand Down Expand Up @@ -213,6 +231,10 @@ impl<'a> Container<'a> {
struct_fields.len() == 1,
fields.span() => "transparent structs and variants can only have 1 field"
);
ensure_spanned!(
options.rename_all.is_none(),
options.rename_all.span() => "`rename_all` is not permitted on `transparent` structs and variants"
);
let field = struct_fields.pop().unwrap();
ensure_spanned!(
field.getter.is_none(),
Expand All @@ -236,6 +258,7 @@ impl<'a> Container<'a> {
path,
ty: style,
err_name,
rename_rule: options.rename_all.map(|v| v.value.rule),
};
Ok(v)
}
Expand Down Expand Up @@ -359,7 +382,11 @@ impl<'a> Container<'a> {
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #name)))
}
FieldGetter::GetAttr(None) => {
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #field_name)))
let name = self
.rename_rule
.map(|rule| utils::apply_renaming_rule(rule, &field_name));
let name = name.as_deref().unwrap_or(&field_name);
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #name)))
}
FieldGetter::GetItem(Some(syn::Lit::Str(key))) => {
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #key)))
Expand All @@ -368,7 +395,11 @@ impl<'a> Container<'a> {
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #key))
}
FieldGetter::GetItem(None) => {
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #field_name)))
let name = self
.rename_rule
.map(|rule| utils::apply_renaming_rule(rule, &field_name));
let name = name.as_deref().unwrap_or(&field_name);
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #name)))
}
};
let extractor = if let Some(FromPyWithAttribute {
Expand Down Expand Up @@ -418,6 +449,8 @@ struct ContainerOptions {
annotation: Option<syn::LitStr>,
/// Change the path for the pyo3 crate
krate: Option<CrateAttribute>,
/// Converts the field idents according to the [RenamingRule] before extraction
rename_all: Option<RenameAllAttribute>,
}

/// Attributes for deriving FromPyObject scoped on containers.
Expand All @@ -430,6 +463,8 @@ enum ContainerPyO3Attribute {
ErrorAnnotation(LitStr),
/// Change the path for the pyo3 crate
Crate(CrateAttribute),
/// Converts the field idents according to the [RenamingRule] before extraction
RenameAll(RenameAllAttribute),
}

impl Parse for ContainerPyO3Attribute {
Expand All @@ -447,6 +482,8 @@ impl Parse for ContainerPyO3Attribute {
input.parse().map(ContainerPyO3Attribute::ErrorAnnotation)
} else if lookahead.peek(Token![crate]) {
input.parse().map(ContainerPyO3Attribute::Crate)
} else if lookahead.peek(attributes::kw::rename_all) {
input.parse().map(ContainerPyO3Attribute::RenameAll)
} else {
Err(lookahead.error())
}
Expand Down Expand Up @@ -489,6 +526,13 @@ impl ContainerOptions {
);
options.krate = Some(path);
}
ContainerPyO3Attribute::RenameAll(rename_all) => {
ensure_spanned!(
options.rename_all.is_none(),
rename_all.span() => "`rename_all` may only be provided once"
);
options.rename_all = Some(rename_all);
}
}
}
}
Expand Down Expand Up @@ -658,7 +702,7 @@ pub fn build_derive_from_pyobject(tokens: &DeriveInput) -> Result<TokenStream> {
bail_spanned!(tokens.span() => "`transparent` or `annotation` is not supported \
at top level for enums");
}
let en = Enum::new(en, &tokens.ident)?;
let en = Enum::new(en, &tokens.ident, options)?;
en.build(ctx)
}
syn::Data::Struct(st) => {
Expand Down
85 changes: 85 additions & 0 deletions tests/test_frompyobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,91 @@ fn test_transparent_tuple_error_message() {
});
}

#[pyclass]
struct RenameAllCls {}

#[pymethods]
impl RenameAllCls {
#[getter]
#[pyo3(name = "someField")]
fn some_field(&self) -> &'static str {
"Foo"
}

#[getter]
#[pyo3(name = "customNumber")]
fn custom_number(&self) -> i32 {
42
}

fn __getitem__(&self, key: &str) -> PyResult<f32> {
match key {
"otherField" => Ok(42.0),
_ => Err(pyo3::exceptions::PyKeyError::new_err("foo")),
}
}
}

#[test]
fn test_struct_rename_all() {
#[derive(FromPyObject)]
#[pyo3(rename_all = "camelCase")]
struct RenameAll {
some_field: String,
#[pyo3(item)]
other_field: f32,
#[pyo3(attribute("customNumber"))]
custom_name: i32,
}

Python::with_gil(|py| {
let RenameAll {
some_field,
other_field,
custom_name,
} = RenameAllCls {}
.into_pyobject(py)
.unwrap()
.extract()
.unwrap();

assert_eq!(some_field, "Foo");
assert_eq!(other_field, 42.0);
assert_eq!(custom_name, 42);
});
}

#[test]
fn test_enum_rename_all() {
#[derive(FromPyObject)]
#[pyo3(rename_all = "camelCase")]
enum RenameAll {
Foo {
some_field: String,
#[pyo3(item)]
other_field: f32,
#[pyo3(attribute("customNumber"))]
custom_name: i32,
},
}

Python::with_gil(|py| {
let RenameAll::Foo {
some_field,
other_field,
custom_name,
} = RenameAllCls {}
.into_pyobject(py)
.unwrap()
.extract()
.unwrap();

assert_eq!(some_field, "Foo");
assert_eq!(other_field, 42.0);
assert_eq!(custom_name, 42);
});
}

#[derive(Debug, FromPyObject)]
pub enum Foo<'py> {
TupleVar(usize, String),
Expand Down
29 changes: 29 additions & 0 deletions tests/ui/invalid_frompy_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,33 @@ enum EnumVariantWithOnlyDefaultValues {
#[derive(FromPyObject)]
struct NamedTuplesWithDefaultValues(#[pyo3(default)] String);

#[derive(FromPyObject)]
#[pyo3(rename_all = "camelCase", rename_all = "kebab-case")]
struct MultipleRenames {
snake_case: String,
}

#[derive(FromPyObject)]
#[pyo3(rename_all = "camelCase")]
struct RenameAllTuple(String);

#[derive(FromPyObject)]
enum RenameAllEnum {
#[pyo3(rename_all = "camelCase")]
Tuple(String),
}

#[derive(FromPyObject)]
#[pyo3(transparent, rename_all = "camelCase")]
struct RenameAllTransparent {
inner: String,
}

#[derive(FromPyObject)]
#[pyo3(rename_all = "camelCase")]
enum UselessRenameAllEnum {
#[pyo3(rename_all = "camelCase")]
Tuple { inner_field: String },
}

fn main() {}
32 changes: 31 additions & 1 deletion tests/ui/invalid_frompy_derive.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ error: only one of `attribute` or `item` can be provided
118 | #[pyo3(item, attribute)]
| ^

error: expected one of: `transparent`, `from_item_all`, `annotation`, `crate`
error: expected one of: `transparent`, `from_item_all`, `annotation`, `crate`, `rename_all`
--> tests/ui/invalid_frompy_derive.rs:123:8
|
123 | #[pyo3(unknown = "should not work")]
Expand Down Expand Up @@ -249,3 +249,33 @@ error: `default` is not permitted on tuple struct elements.
|
231 | struct NamedTuplesWithDefaultValues(#[pyo3(default)] String);
| ^

error: `rename_all` may only be provided once
--> tests/ui/invalid_frompy_derive.rs:234:34
|
234 | #[pyo3(rename_all = "camelCase", rename_all = "kebab-case")]
| ^^^^^^^^^^

error: `rename_all` is useless on tuple structs and variants.
--> tests/ui/invalid_frompy_derive.rs:240:8
|
240 | #[pyo3(rename_all = "camelCase")]
| ^^^^^^^^^^

error: `rename_all` is useless on tuple structs and variants.
--> tests/ui/invalid_frompy_derive.rs:245:12
|
245 | #[pyo3(rename_all = "camelCase")]
| ^^^^^^^^^^

error: `rename_all` is not permitted on `transparent` structs and variants
--> tests/ui/invalid_frompy_derive.rs:250:21
|
250 | #[pyo3(transparent, rename_all = "camelCase")]
| ^^^^^^^^^^

error: Useless variant `rename_all` - enum is already annotated with `rename_all
--> tests/ui/invalid_frompy_derive.rs:258:12
|
258 | #[pyo3(rename_all = "camelCase")]
| ^^^^^^^^^^

0 comments on commit 0dd3887

Please sign in to comment.