Skip to content

Commit 0dd3887

Browse files
committed
added #[pyo3(rename_all = "...")] container attribute for #[derive(FromPyObject)]
1 parent 241080a commit 0dd3887

File tree

6 files changed

+201
-8
lines changed

6 files changed

+201
-8
lines changed

guide/src/conversions/traits.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ If the input is neither a string nor an integer, the error message will be:
476476
- changes the name of the failed variant in the generated error message in case of failure.
477477
- e.g. `pyo3("int")` reports the variant's type as `int`.
478478
- only supported for enum variants
479+
- `pyo3(rename_all = "...")`
480+
- renames all attributes/item keys according to the specified renaming rule
481+
- Possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE".
482+
- fields with an explicit renaming via `attribute(...)`/`item(...)` are not affected
479483

480484
#### `#[derive(FromPyObject)]` Field Attributes
481485
- `pyo3(attribute)`, `pyo3(attribute("name"))`

newsfragments/4941.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
add `#[pyo3(rename_all = "...")]` for `#[derive(FromPyObject)]`

pyo3-macros-backend/src/frompyobject.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use crate::attributes::{
22
self, get_pyo3_options, CrateAttribute, DefaultAttribute, FromPyWithAttribute,
3+
RenameAllAttribute, RenamingRule,
34
};
4-
use crate::utils::Ctx;
5+
use crate::utils::{self, Ctx};
56
use proc_macro2::TokenStream;
67
use quote::{format_ident, quote, quote_spanned, ToTokens};
78
use syn::{
@@ -25,7 +26,7 @@ impl<'a> Enum<'a> {
2526
///
2627
/// `data_enum` is the `syn` representation of the input enum, `ident` is the
2728
/// `Identifier` of the enum.
28-
fn new(data_enum: &'a DataEnum, ident: &'a Ident) -> Result<Self> {
29+
fn new(data_enum: &'a DataEnum, ident: &'a Ident, options: ContainerOptions) -> Result<Self> {
2930
ensure_spanned!(
3031
!data_enum.variants.is_empty(),
3132
ident.span() => "cannot derive FromPyObject for empty enum"
@@ -34,9 +35,21 @@ impl<'a> Enum<'a> {
3435
.variants
3536
.iter()
3637
.map(|variant| {
37-
let attrs = ContainerOptions::from_attrs(&variant.attrs)?;
38+
let mut variant_options = ContainerOptions::from_attrs(&variant.attrs)?;
39+
if let Some(rename_all) = &options.rename_all {
40+
ensure_spanned!(
41+
variant_options.rename_all.is_none(),
42+
variant_options.rename_all.span() => "Useless variant `rename_all` - enum is already annotated with `rename_all"
43+
);
44+
variant_options.rename_all = Some(rename_all.clone());
45+
46+
}
3847
let var_ident = &variant.ident;
39-
Container::new(&variant.fields, parse_quote!(#ident::#var_ident), attrs)
48+
Container::new(
49+
&variant.fields,
50+
parse_quote!(#ident::#var_ident),
51+
variant_options,
52+
)
4053
})
4154
.collect::<Result<Vec<_>>>()?;
4255

@@ -129,6 +142,7 @@ struct Container<'a> {
129142
path: syn::Path,
130143
ty: ContainerType<'a>,
131144
err_name: String,
145+
rename_rule: Option<RenamingRule>,
132146
}
133147

134148
impl<'a> Container<'a> {
@@ -138,6 +152,10 @@ impl<'a> Container<'a> {
138152
fn new(fields: &'a Fields, path: syn::Path, options: ContainerOptions) -> Result<Self> {
139153
let style = match fields {
140154
Fields::Unnamed(unnamed) if !unnamed.unnamed.is_empty() => {
155+
ensure_spanned!(
156+
options.rename_all.is_none(),
157+
options.rename_all.span() => "`rename_all` is useless on tuple structs and variants."
158+
);
141159
let mut tuple_fields = unnamed
142160
.unnamed
143161
.iter()
@@ -213,6 +231,10 @@ impl<'a> Container<'a> {
213231
struct_fields.len() == 1,
214232
fields.span() => "transparent structs and variants can only have 1 field"
215233
);
234+
ensure_spanned!(
235+
options.rename_all.is_none(),
236+
options.rename_all.span() => "`rename_all` is not permitted on `transparent` structs and variants"
237+
);
216238
let field = struct_fields.pop().unwrap();
217239
ensure_spanned!(
218240
field.getter.is_none(),
@@ -236,6 +258,7 @@ impl<'a> Container<'a> {
236258
path,
237259
ty: style,
238260
err_name,
261+
rename_rule: options.rename_all.map(|v| v.value.rule),
239262
};
240263
Ok(v)
241264
}
@@ -359,7 +382,11 @@ impl<'a> Container<'a> {
359382
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #name)))
360383
}
361384
FieldGetter::GetAttr(None) => {
362-
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #field_name)))
385+
let name = self
386+
.rename_rule
387+
.map(|rule| utils::apply_renaming_rule(rule, &field_name));
388+
let name = name.as_deref().unwrap_or(&field_name);
389+
quote!(#pyo3_path::types::PyAnyMethods::getattr(obj, #pyo3_path::intern!(obj.py(), #name)))
363390
}
364391
FieldGetter::GetItem(Some(syn::Lit::Str(key))) => {
365392
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #key)))
@@ -368,7 +395,11 @@ impl<'a> Container<'a> {
368395
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #key))
369396
}
370397
FieldGetter::GetItem(None) => {
371-
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #field_name)))
398+
let name = self
399+
.rename_rule
400+
.map(|rule| utils::apply_renaming_rule(rule, &field_name));
401+
let name = name.as_deref().unwrap_or(&field_name);
402+
quote!(#pyo3_path::types::PyAnyMethods::get_item(obj, #pyo3_path::intern!(obj.py(), #name)))
372403
}
373404
};
374405
let extractor = if let Some(FromPyWithAttribute {
@@ -418,6 +449,8 @@ struct ContainerOptions {
418449
annotation: Option<syn::LitStr>,
419450
/// Change the path for the pyo3 crate
420451
krate: Option<CrateAttribute>,
452+
/// Converts the field idents according to the [RenamingRule] before extraction
453+
rename_all: Option<RenameAllAttribute>,
421454
}
422455

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

435470
impl Parse for ContainerPyO3Attribute {
@@ -447,6 +482,8 @@ impl Parse for ContainerPyO3Attribute {
447482
input.parse().map(ContainerPyO3Attribute::ErrorAnnotation)
448483
} else if lookahead.peek(Token![crate]) {
449484
input.parse().map(ContainerPyO3Attribute::Crate)
485+
} else if lookahead.peek(attributes::kw::rename_all) {
486+
input.parse().map(ContainerPyO3Attribute::RenameAll)
450487
} else {
451488
Err(lookahead.error())
452489
}
@@ -489,6 +526,13 @@ impl ContainerOptions {
489526
);
490527
options.krate = Some(path);
491528
}
529+
ContainerPyO3Attribute::RenameAll(rename_all) => {
530+
ensure_spanned!(
531+
options.rename_all.is_none(),
532+
rename_all.span() => "`rename_all` may only be provided once"
533+
);
534+
options.rename_all = Some(rename_all);
535+
}
492536
}
493537
}
494538
}
@@ -658,7 +702,7 @@ pub fn build_derive_from_pyobject(tokens: &DeriveInput) -> Result<TokenStream> {
658702
bail_spanned!(tokens.span() => "`transparent` or `annotation` is not supported \
659703
at top level for enums");
660704
}
661-
let en = Enum::new(en, &tokens.ident)?;
705+
let en = Enum::new(en, &tokens.ident, options)?;
662706
en.build(ctx)
663707
}
664708
syn::Data::Struct(st) => {

tests/test_frompyobject.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,91 @@ fn test_transparent_tuple_error_message() {
331331
});
332332
}
333333

334+
#[pyclass]
335+
struct RenameAllCls {}
336+
337+
#[pymethods]
338+
impl RenameAllCls {
339+
#[getter]
340+
#[pyo3(name = "someField")]
341+
fn some_field(&self) -> &'static str {
342+
"Foo"
343+
}
344+
345+
#[getter]
346+
#[pyo3(name = "customNumber")]
347+
fn custom_number(&self) -> i32 {
348+
42
349+
}
350+
351+
fn __getitem__(&self, key: &str) -> PyResult<f32> {
352+
match key {
353+
"otherField" => Ok(42.0),
354+
_ => Err(pyo3::exceptions::PyKeyError::new_err("foo")),
355+
}
356+
}
357+
}
358+
359+
#[test]
360+
fn test_struct_rename_all() {
361+
#[derive(FromPyObject)]
362+
#[pyo3(rename_all = "camelCase")]
363+
struct RenameAll {
364+
some_field: String,
365+
#[pyo3(item)]
366+
other_field: f32,
367+
#[pyo3(attribute("customNumber"))]
368+
custom_name: i32,
369+
}
370+
371+
Python::with_gil(|py| {
372+
let RenameAll {
373+
some_field,
374+
other_field,
375+
custom_name,
376+
} = RenameAllCls {}
377+
.into_pyobject(py)
378+
.unwrap()
379+
.extract()
380+
.unwrap();
381+
382+
assert_eq!(some_field, "Foo");
383+
assert_eq!(other_field, 42.0);
384+
assert_eq!(custom_name, 42);
385+
});
386+
}
387+
388+
#[test]
389+
fn test_enum_rename_all() {
390+
#[derive(FromPyObject)]
391+
#[pyo3(rename_all = "camelCase")]
392+
enum RenameAll {
393+
Foo {
394+
some_field: String,
395+
#[pyo3(item)]
396+
other_field: f32,
397+
#[pyo3(attribute("customNumber"))]
398+
custom_name: i32,
399+
},
400+
}
401+
402+
Python::with_gil(|py| {
403+
let RenameAll::Foo {
404+
some_field,
405+
other_field,
406+
custom_name,
407+
} = RenameAllCls {}
408+
.into_pyobject(py)
409+
.unwrap()
410+
.extract()
411+
.unwrap();
412+
413+
assert_eq!(some_field, "Foo");
414+
assert_eq!(other_field, 42.0);
415+
assert_eq!(custom_name, 42);
416+
});
417+
}
418+
334419
#[derive(Debug, FromPyObject)]
335420
pub enum Foo<'py> {
336421
TupleVar(usize, String),

tests/ui/invalid_frompy_derive.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,33 @@ enum EnumVariantWithOnlyDefaultValues {
230230
#[derive(FromPyObject)]
231231
struct NamedTuplesWithDefaultValues(#[pyo3(default)] String);
232232

233+
#[derive(FromPyObject)]
234+
#[pyo3(rename_all = "camelCase", rename_all = "kebab-case")]
235+
struct MultipleRenames {
236+
snake_case: String,
237+
}
238+
239+
#[derive(FromPyObject)]
240+
#[pyo3(rename_all = "camelCase")]
241+
struct RenameAllTuple(String);
242+
243+
#[derive(FromPyObject)]
244+
enum RenameAllEnum {
245+
#[pyo3(rename_all = "camelCase")]
246+
Tuple(String),
247+
}
248+
249+
#[derive(FromPyObject)]
250+
#[pyo3(transparent, rename_all = "camelCase")]
251+
struct RenameAllTransparent {
252+
inner: String,
253+
}
254+
255+
#[derive(FromPyObject)]
256+
#[pyo3(rename_all = "camelCase")]
257+
enum UselessRenameAllEnum {
258+
#[pyo3(rename_all = "camelCase")]
259+
Tuple { inner_field: String },
260+
}
261+
233262
fn main() {}

tests/ui/invalid_frompy_derive.stderr

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ error: only one of `attribute` or `item` can be provided
132132
118 | #[pyo3(item, attribute)]
133133
| ^
134134

135-
error: expected one of: `transparent`, `from_item_all`, `annotation`, `crate`
135+
error: expected one of: `transparent`, `from_item_all`, `annotation`, `crate`, `rename_all`
136136
--> tests/ui/invalid_frompy_derive.rs:123:8
137137
|
138138
123 | #[pyo3(unknown = "should not work")]
@@ -249,3 +249,33 @@ error: `default` is not permitted on tuple struct elements.
249249
|
250250
231 | struct NamedTuplesWithDefaultValues(#[pyo3(default)] String);
251251
| ^
252+
253+
error: `rename_all` may only be provided once
254+
--> tests/ui/invalid_frompy_derive.rs:234:34
255+
|
256+
234 | #[pyo3(rename_all = "camelCase", rename_all = "kebab-case")]
257+
| ^^^^^^^^^^
258+
259+
error: `rename_all` is useless on tuple structs and variants.
260+
--> tests/ui/invalid_frompy_derive.rs:240:8
261+
|
262+
240 | #[pyo3(rename_all = "camelCase")]
263+
| ^^^^^^^^^^
264+
265+
error: `rename_all` is useless on tuple structs and variants.
266+
--> tests/ui/invalid_frompy_derive.rs:245:12
267+
|
268+
245 | #[pyo3(rename_all = "camelCase")]
269+
| ^^^^^^^^^^
270+
271+
error: `rename_all` is not permitted on `transparent` structs and variants
272+
--> tests/ui/invalid_frompy_derive.rs:250:21
273+
|
274+
250 | #[pyo3(transparent, rename_all = "camelCase")]
275+
| ^^^^^^^^^^
276+
277+
error: Useless variant `rename_all` - enum is already annotated with `rename_all
278+
--> tests/ui/invalid_frompy_derive.rs:258:12
279+
|
280+
258 | #[pyo3(rename_all = "camelCase")]
281+
| ^^^^^^^^^^

0 commit comments

Comments
 (0)