diff --git a/guide/pyclass_parameters.md b/guide/pyclass_parameters.md
index 0ed15be6315..edc69eb3cf5 100644
--- a/guide/pyclass_parameters.md
+++ b/guide/pyclass_parameters.md
@@ -11,6 +11,7 @@
| `mapping` | Inform PyO3 that this class is a [`Mapping`][params-mapping], and so leave its implementation of sequence C-API slots empty. |
| `module = "module_name"` | Python code will see the class as being defined in this module. Defaults to `builtins`. |
| `name = "python_name"` | Sets the name that Python sees this class as. Defaults to the name of the Rust struct. |
+| `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. |
| `subclass` | Allows other Python classes and `#[pyclass]` to inherit from this class. Enums cannot be subclassed. |
diff --git a/newsfragments/3384.added.md b/newsfragments/3384.added.md
new file mode 100644
index 00000000000..d4732d5d5b3
--- /dev/null
+++ b/newsfragments/3384.added.md
@@ -0,0 +1 @@
+`#[pyclass]` now accepts `rename_all = "renaming_rule"`: this allows renaming all getters and setters of a struct, or all variants of an enum. Available renaming rules are: `"camelCase"`, `"kebab-case"`, `"lowercase"`, `"PascalCase"`, `"SCREAMING-KEBAB-CASE"`, `"SCREAMING_SNAKE_CASE"`, `"snake_case"`, `"UPPERCASE"`.
diff --git a/pyo3-macros-backend/Cargo.toml b/pyo3-macros-backend/Cargo.toml
index eb4417bd118..d782a7d9dbb 100644
--- a/pyo3-macros-backend/Cargo.toml
+++ b/pyo3-macros-backend/Cargo.toml
@@ -16,6 +16,7 @@ edition = "2021"
[dependencies]
quote = { version = "1", default-features = false }
proc-macro2 = { version = "1", default-features = false }
+heck = "0.4"
[dependencies.syn]
version = "2"
diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs
index e51a5a043d3..e5e91cef42c 100644
--- a/pyo3-macros-backend/src/attributes.rs
+++ b/pyo3-macros-backend/src/attributes.rs
@@ -26,6 +26,7 @@ pub mod kw {
syn::custom_keyword!(module);
syn::custom_keyword!(name);
syn::custom_keyword!(pass_module);
+ syn::custom_keyword!(rename_all);
syn::custom_keyword!(sequence);
syn::custom_keyword!(set);
syn::custom_keyword!(set_all);
@@ -82,6 +83,55 @@ impl ToTokens for NameLitStr {
}
}
+/// Available renaming rules
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RenamingRule {
+ CamelCase,
+ KebabCase,
+ Lowercase,
+ PascalCase,
+ ScreamingKebabCase,
+ ScreamingSnakeCase,
+ SnakeCase,
+ Uppercase,
+}
+
+/// A helper type which parses a renaming rule via a literal string
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RenamingRuleLitStr {
+ pub lit: LitStr,
+ pub rule: RenamingRule,
+}
+
+impl Parse for RenamingRuleLitStr {
+ fn parse(input: ParseStream<'_>) -> Result {
+ let string_literal: LitStr = input.parse()?;
+ let rule = match string_literal.value().as_ref() {
+ "camelCase" => RenamingRule::CamelCase,
+ "kebab-case" => RenamingRule::KebabCase,
+ "lowercase" => RenamingRule::Lowercase,
+ "PascalCase" => RenamingRule::PascalCase,
+ "SCREAMING-KEBAB-CASE" => RenamingRule::ScreamingKebabCase,
+ "SCREAMING_SNAKE_CASE" => RenamingRule::ScreamingSnakeCase,
+ "snake_case" => RenamingRule::SnakeCase,
+ "UPPERCASE" => RenamingRule::Uppercase,
+ _ => {
+ bail_spanned!(string_literal.span() => "expected a valid renaming rule, possible values are: \"camelCase\", \"kebab-case\", \"lowercase\", \"PascalCase\", \"SCREAMING-KEBAB-CASE\", \"SCREAMING_SNAKE_CASE\", \"snake_case\", \"UPPERCASE\"")
+ }
+ };
+ Ok(Self {
+ lit: string_literal,
+ rule,
+ })
+ }
+}
+
+impl ToTokens for RenamingRuleLitStr {
+ fn to_tokens(&self, tokens: &mut TokenStream) {
+ self.lit.to_tokens(tokens)
+ }
+}
+
/// Text signatue can be either a literal string or opt-in/out
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TextSignatureAttributeValue {
@@ -121,6 +171,7 @@ pub type ExtendsAttribute = KeywordAttribute;
pub type FreelistAttribute = KeywordAttribute>;
pub type ModuleAttribute = KeywordAttribute;
pub type NameAttribute = KeywordAttribute;
+pub type RenameAllAttribute = KeywordAttribute;
pub type TextSignatureAttribute = KeywordAttribute;
impl Parse for KeywordAttribute {
diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs
index 2494f562a44..c2cb66d76dd 100644
--- a/pyo3-macros-backend/src/pyclass.rs
+++ b/pyo3-macros-backend/src/pyclass.rs
@@ -3,7 +3,7 @@ use std::borrow::Cow;
use crate::attributes::kw::frozen;
use crate::attributes::{
self, kw, take_pyo3_options, CrateAttribute, ExtendsAttribute, FreelistAttribute,
- ModuleAttribute, NameAttribute, NameLitStr, TextSignatureAttribute,
+ ModuleAttribute, NameAttribute, NameLitStr, RenameAllAttribute, TextSignatureAttribute,
TextSignatureAttributeValue,
};
use crate::deprecations::{Deprecation, Deprecations};
@@ -14,9 +14,9 @@ use crate::pymethod::{
impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, MethodAndSlotDef, PropertyType,
SlotDef, __INT__, __REPR__, __RICHCMP__,
};
-use crate::utils::{self, get_pyo3_crate, PythonDoc};
+use crate::utils::{self, apply_renaming_rule, get_pyo3_crate, PythonDoc};
use crate::PyFunctionOptions;
-use proc_macro2::{Span, TokenStream};
+use proc_macro2::{Ident, Span, TokenStream};
use quote::quote;
use syn::ext::IdentExt;
use syn::parse::{Parse, ParseStream};
@@ -66,6 +66,7 @@ pub struct PyClassPyO3Options {
pub mapping: Option,
pub module: Option,
pub name: Option,
+ pub rename_all: Option,
pub sequence: Option,
pub set_all: Option,
pub subclass: Option,
@@ -86,6 +87,7 @@ enum PyClassPyO3Option {
Mapping(kw::mapping),
Module(ModuleAttribute),
Name(NameAttribute),
+ RenameAll(RenameAllAttribute),
Sequence(kw::sequence),
SetAll(kw::set_all),
Subclass(kw::subclass),
@@ -115,6 +117,8 @@ impl Parse for PyClassPyO3Option {
input.parse().map(PyClassPyO3Option::Module)
} else if lookahead.peek(kw::name) {
input.parse().map(PyClassPyO3Option::Name)
+ } else if lookahead.peek(kw::rename_all) {
+ input.parse().map(PyClassPyO3Option::RenameAll)
} else if lookahead.peek(attributes::kw::sequence) {
input.parse().map(PyClassPyO3Option::Sequence)
} else if lookahead.peek(attributes::kw::set_all) {
@@ -173,6 +177,7 @@ impl PyClassPyO3Options {
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
PyClassPyO3Option::Module(module) => set_option!(module),
PyClassPyO3Option::Name(name) => set_option!(name),
+ PyClassPyO3Option::RenameAll(rename_all) => set_option!(rename_all),
PyClassPyO3Option::Sequence(sequence) => set_option!(sequence),
PyClassPyO3Option::SetAll(set_all) => set_option!(set_all),
PyClassPyO3Option::Subclass(subclass) => set_option!(subclass),
@@ -356,7 +361,12 @@ fn impl_class(
cls,
args,
methods_type,
- descriptors_to_items(cls, args.options.frozen, field_options)?,
+ descriptors_to_items(
+ cls,
+ args.options.rename_all.as_ref(),
+ args.options.frozen,
+ field_options,
+ )?,
vec![],
)
.doc(doc)
@@ -379,12 +389,20 @@ struct PyClassEnumVariant<'a> {
}
impl<'a> PyClassEnumVariant<'a> {
- fn python_name(&self) -> Cow<'_, syn::Ident> {
+ fn python_name(&self, args: &PyClassArgs) -> Cow<'_, syn::Ident> {
self.options
.name
.as_ref()
.map(|name_attr| Cow::Borrowed(&name_attr.value.0))
- .unwrap_or_else(|| Cow::Owned(self.ident.unraw()))
+ .unwrap_or_else(|| {
+ let name = self.ident.unraw();
+ if let Some(attr) = &args.options.rename_all {
+ let new_name = apply_renaming_rule(attr.value.rule, &name.to_string());
+ Cow::Owned(Ident::new(&new_name, Span::call_site()))
+ } else {
+ Cow::Owned(name)
+ }
+ })
}
}
@@ -515,7 +533,7 @@ fn impl_enum(
let repr = format!(
"{}.{}",
get_class_python_name(cls, args),
- variant.python_name(),
+ variant.python_name(args),
);
quote! { #cls::#variant_name => #repr, }
});
@@ -597,7 +615,7 @@ fn impl_enum(
cls,
args,
methods_type,
- enum_default_methods(cls, variants.iter().map(|v| (v.ident, v.python_name()))),
+ enum_default_methods(cls, variants.iter().map(|v| (v.ident, v.python_name(args)))),
default_slots,
)
.doc(doc)
@@ -675,6 +693,7 @@ fn extract_variant_data(variant: &mut syn::Variant) -> syn::Result,
frozen: Option,
field_options: Vec<(&syn::Field, FieldPyO3Options)>,
) -> syn::Result> {
@@ -697,6 +716,7 @@ fn descriptors_to_items(
field_index,
field,
python_name: options.name.as_ref(),
+ renaming_rule: rename_all.map(|rename_all| rename_all.value.rule),
},
)?;
items.push(getter);
@@ -710,6 +730,7 @@ fn descriptors_to_items(
field_index,
field,
python_name: options.name.as_ref(),
+ renaming_rule: rename_all.map(|rename_all| rename_all.value.rule),
},
)?;
items.push(setter);
diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs
index 0e2dc1006dd..5fd74ec2168 100644
--- a/pyo3-macros-backend/src/pymethod.rs
+++ b/pyo3-macros-backend/src/pymethod.rs
@@ -1,6 +1,6 @@
use std::borrow::Cow;
-use crate::attributes::NameAttribute;
+use crate::attributes::{NameAttribute, RenamingRule};
use crate::method::{CallingConvention, ExtractErrorMode};
use crate::utils::{ensure_not_async_fn, PythonDoc};
use crate::{
@@ -724,6 +724,7 @@ pub enum PropertyType<'a> {
field_index: usize,
field: &'a syn::Field,
python_name: Option<&'a NameAttribute>,
+ renaming_rule: Option,
},
Function {
self_type: &'a SelfType,
@@ -736,11 +737,21 @@ impl PropertyType<'_> {
fn null_terminated_python_name(&self) -> Result {
match self {
PropertyType::Descriptor {
- field, python_name, ..
+ field,
+ python_name,
+ renaming_rule,
+ ..
} => {
let name = match (python_name, &field.ident) {
(Some(name), _) => name.value.0.to_string(),
- (None, Some(field_name)) => format!("{}\0", field_name.unraw()),
+ (None, Some(field_name)) => {
+ let mut name = field_name.unraw().to_string();
+ if let Some(rule) = renaming_rule {
+ name = utils::apply_renaming_rule(*rule, &name);
+ }
+ name.push('\0');
+ name
+ }
(None, None) => {
bail_spanned!(field.span() => "`get` and `set` with tuple struct fields require `name`");
}
diff --git a/pyo3-macros-backend/src/utils.rs b/pyo3-macros-backend/src/utils.rs
index 7bb8d240fd2..65da11f28e1 100644
--- a/pyo3-macros-backend/src/utils.rs
+++ b/pyo3-macros-backend/src/utils.rs
@@ -2,7 +2,7 @@ use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use syn::{punctuated::Punctuated, spanned::Spanned, Token};
-use crate::attributes::CrateAttribute;
+use crate::attributes::{CrateAttribute, RenamingRule};
/// Macro inspired by `anyhow::anyhow!` to create a compiler error with the given span.
macro_rules! err_spanned {
@@ -161,3 +161,18 @@ pub(crate) fn get_pyo3_crate(attr: &Option) -> syn::Path {
.map(|p| p.value.0.clone())
.unwrap_or_else(|| syn::parse_str("::pyo3").unwrap())
}
+
+pub fn apply_renaming_rule(rule: RenamingRule, name: &str) -> String {
+ use heck::*;
+
+ match rule {
+ RenamingRule::CamelCase => name.to_lower_camel_case(),
+ RenamingRule::KebabCase => name.to_kebab_case(),
+ RenamingRule::Lowercase => name.to_lowercase(),
+ RenamingRule::PascalCase => name.to_upper_camel_case(),
+ RenamingRule::ScreamingKebabCase => name.to_shouty_kebab_case(),
+ RenamingRule::ScreamingSnakeCase => name.to_shouty_snake_case(),
+ RenamingRule::SnakeCase => name.to_snake_case(),
+ RenamingRule::Uppercase => name.to_uppercase(),
+ }
+}
diff --git a/tests/test_class_attributes.rs b/tests/test_class_attributes.rs
index 781cc799cf9..bb88e81c898 100644
--- a/tests/test_class_attributes.rs
+++ b/tests/test_class_attributes.rs
@@ -160,3 +160,128 @@ RuntimeError: An error occurred while initializing class BrokenClass"
)
});
}
+
+#[pyclass(get_all, set_all, rename_all = "camelCase")]
+struct StructWithRenamedFields {
+ first_field: bool,
+ second_field: u8,
+ #[pyo3(name = "third_field")]
+ fourth_field: bool,
+}
+
+#[pymethods]
+impl StructWithRenamedFields {
+ #[new]
+ fn new() -> Self {
+ Self {
+ first_field: true,
+ second_field: 5,
+ fourth_field: false,
+ }
+ }
+}
+
+#[test]
+fn test_renaming_all_struct_fields() {
+ use pyo3::types::PyBool;
+
+ Python::with_gil(|py| {
+ let struct_class = py.get_type::();
+ let struct_obj = struct_class.call0().unwrap();
+ assert!(struct_obj
+ .setattr("firstField", PyBool::new(py, false))
+ .is_ok());
+ py_assert!(py, struct_obj, "struct_obj.firstField == False");
+ py_assert!(py, struct_obj, "struct_obj.secondField == 5");
+ assert!(struct_obj
+ .setattr("third_field", PyBool::new(py, true))
+ .is_ok());
+ py_assert!(py, struct_obj, "struct_obj.third_field == True");
+ });
+}
+
+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)]
+ #[allow(non_snake_case)]
+ struct $struct_name {
+ $field_name: u8,
+ }
+ #[pymethods]
+ impl $struct_name {
+ #[new]
+ fn new() -> Self {
+ Self { $field_name: 0 }
+ }
+ }
+ #[test]
+ fn $test_name() {
+ //use pyo3::types::PyInt;
+
+ Python::with_gil(|py| {
+ let struct_class = py.get_type::<$struct_name>();
+ let struct_obj = struct_class.call0().unwrap();
+ assert!(struct_obj.setattr($renamed_field_name, 2).is_ok());
+ let attr = struct_obj.getattr($renamed_field_name).unwrap();
+ assert_eq!(2, PyAny::extract::(attr).unwrap());
+ });
+ }
+ };
+}
+
+test_case!(
+ LowercaseTest,
+ "lowercase",
+ fieldOne,
+ "fieldone",
+ test_rename_all_lowercase
+);
+test_case!(
+ CamelCaseTest,
+ "camelCase",
+ field_one,
+ "fieldOne",
+ test_rename_all_camel_case
+);
+test_case!(
+ KebabCaseTest,
+ "kebab-case",
+ field_one,
+ "field-one",
+ test_rename_all_kebab_case
+);
+test_case!(
+ PascalCaseTest,
+ "PascalCase",
+ field_one,
+ "FieldOne",
+ test_rename_all_pascal_case
+);
+test_case!(
+ ScreamingSnakeCaseTest,
+ "SCREAMING_SNAKE_CASE",
+ field_one,
+ "FIELD_ONE",
+ test_rename_all_screaming_snake_case
+);
+test_case!(
+ ScreamingKebabCaseTest,
+ "SCREAMING-KEBAB-CASE",
+ field_one,
+ "FIELD-ONE",
+ test_rename_all_screaming_kebab_case
+);
+test_case!(
+ SnakeCaseTest,
+ "snake_case",
+ fieldOne,
+ "field_one",
+ test_rename_all_snake_case
+);
+test_case!(
+ UppercaseTest,
+ "UPPERCASE",
+ fieldOne,
+ "FIELDONE",
+ test_rename_all_uppercase
+);
diff --git a/tests/test_enum.rs b/tests/test_enum.rs
index 994fc36e83e..a1b3a974523 100644
--- a/tests/test_enum.rs
+++ b/tests/test_enum.rs
@@ -190,3 +190,26 @@ fn test_rename_variant_repr_correct() {
py_assert!(py, var1, "repr(var1) == 'RenameVariantEnum.VARIANT'");
})
}
+
+#[pyclass(rename_all = "SCREAMING_SNAKE_CASE")]
+#[allow(clippy::enum_variant_names)]
+enum RenameAllVariantsEnum {
+ VariantOne,
+ VariantTwo,
+ #[pyo3(name = "VariantThree")]
+ VariantFour,
+}
+
+#[test]
+fn test_renaming_all_enum_variants() {
+ Python::with_gil(|py| {
+ let enum_obj = py.get_type::();
+ py_assert!(py, enum_obj, "enum_obj.VARIANT_ONE == enum_obj.VARIANT_ONE");
+ py_assert!(py, enum_obj, "enum_obj.VARIANT_TWO == enum_obj.VARIANT_TWO");
+ py_assert!(
+ py,
+ enum_obj,
+ "enum_obj.VariantThree == enum_obj.VariantThree"
+ );
+ });
+}
diff --git a/tests/ui/invalid_pyclass_args.rs b/tests/ui/invalid_pyclass_args.rs
index c2ff187aadf..fac21db078c 100644
--- a/tests/ui/invalid_pyclass_args.rs
+++ b/tests/ui/invalid_pyclass_args.rs
@@ -15,6 +15,12 @@ struct InvalidName2 {}
#[pyclass(name = CustomName)]
struct DeprecatedName {}
+#[pyclass(rename_all = camelCase)]
+struct InvalidRenamingRule {}
+
+#[pyclass(rename_all = "Camel-Case")]
+struct InvalidRenamingRule2 {}
+
#[pyclass(module = my_module)]
struct InvalidModule {}
diff --git a/tests/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr
index 1f45715fe90..859f0f409d3 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`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `sequence`, `set_all`, `subclass`, `text_signature`, `unsendable`, `weakref`
+error: expected one of: `crate`, `dict`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `text_signature`, `unsendable`, `weakref`
--> tests/ui/invalid_pyclass_args.rs:3:11
|
3 | #[pyclass(extend=pyo3::types::PyDict)]
@@ -29,19 +29,31 @@ error: expected string literal
| ^^^^^^^^^^
error: expected string literal
- --> tests/ui/invalid_pyclass_args.rs:18:20
+ --> tests/ui/invalid_pyclass_args.rs:18:24
|
-18 | #[pyclass(module = my_module)]
+18 | #[pyclass(rename_all = camelCase)]
+ | ^^^^^^^^^
+
+error: expected a valid renaming rule, possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE"
+ --> tests/ui/invalid_pyclass_args.rs:21:24
+ |
+21 | #[pyclass(rename_all = "Camel-Case")]
+ | ^^^^^^^^^^^^
+
+error: expected string literal
+ --> tests/ui/invalid_pyclass_args.rs:24:20
+ |
+24 | #[pyclass(module = my_module)]
| ^^^^^^^^^
-error: expected one of: `crate`, `dict`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `sequence`, `set_all`, `subclass`, `text_signature`, `unsendable`, `weakref`
- --> tests/ui/invalid_pyclass_args.rs:21:11
+error: expected one of: `crate`, `dict`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `text_signature`, `unsendable`, `weakref`
+ --> tests/ui/invalid_pyclass_args.rs:27:11
|
-21 | #[pyclass(weakrev)]
+27 | #[pyclass(weakrev)]
| ^^^^^^^
error: a `#[pyclass]` cannot be both a `mapping` and a `sequence`
- --> tests/ui/invalid_pyclass_args.rs:25:8
+ --> tests/ui/invalid_pyclass_args.rs:31:8
|
-25 | struct CannotBeMappingAndSequence {}
+31 | struct CannotBeMappingAndSequence {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^