Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rename_all attribute to #[pyclass] #3384

Merged
merged 5 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -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. |
| <span style="white-space: pre">`module = "module_name"`</span> | Python code will see the class as being defined in this module. Defaults to `builtins`. |
| <span style="white-space: pre">`name = "python_name"`</span> | 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. |
Expand Down
1 change: 1 addition & 0 deletions newsfragments/3384.added.md
Original file line number Diff line number Diff line change
@@ -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"`.
1 change: 1 addition & 0 deletions pyo3-macros-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions pyo3-macros-backend/src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<Self> {
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 {
Expand Down Expand Up @@ -121,6 +171,7 @@ 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 TextSignatureAttribute = KeywordAttribute<kw::text_signature, TextSignatureAttributeValue>;

impl<K: Parse + std::fmt::Debug, V: Parse> Parse for KeywordAttribute<K, V> {
Expand Down
37 changes: 29 additions & 8 deletions pyo3-macros-backend/src/pyclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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};
Expand Down Expand Up @@ -66,6 +66,7 @@ pub struct PyClassPyO3Options {
pub mapping: Option<kw::mapping>,
pub module: Option<ModuleAttribute>,
pub name: Option<NameAttribute>,
pub rename_all: Option<RenameAllAttribute>,
pub sequence: Option<kw::sequence>,
pub set_all: Option<kw::set_all>,
pub subclass: Option<kw::subclass>,
Expand All @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
})
}
}

Expand Down Expand Up @@ -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, }
});
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -675,6 +693,7 @@ fn extract_variant_data(variant: &mut syn::Variant) -> syn::Result<PyClassEnumVa

fn descriptors_to_items(
cls: &syn::Ident,
rename_all: Option<&RenameAllAttribute>,
frozen: Option<frozen>,
field_options: Vec<(&syn::Field, FieldPyO3Options)>,
) -> syn::Result<Vec<MethodAndMethodDef>> {
Expand All @@ -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);
Expand All @@ -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);
Expand Down
17 changes: 14 additions & 3 deletions pyo3-macros-backend/src/pymethod.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -724,6 +724,7 @@ pub enum PropertyType<'a> {
field_index: usize,
field: &'a syn::Field,
python_name: Option<&'a NameAttribute>,
renaming_rule: Option<RenamingRule>,
},
Function {
self_type: &'a SelfType,
Expand All @@ -736,11 +737,21 @@ impl PropertyType<'_> {
fn null_terminated_python_name(&self) -> Result<syn::LitStr> {
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`");
}
Expand Down
17 changes: 16 additions & 1 deletion pyo3-macros-backend/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -161,3 +161,18 @@ pub(crate) fn get_pyo3_crate(attr: &Option<CrateAttribute>) -> 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(),
}
}
39 changes: 39 additions & 0 deletions tests/test_class_attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,42 @@ 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::<StructWithRenamedFields>();
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");
});
Comment on lines +186 to +200
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 quite nice to have some kind of macro here which can test each type of rename_all in turn, so that we test they all work as expected.

test_case!(CamelCase, "camelCase", "ab_cd-ef", "abCdEf");
test_case!(SnakeCase, "snake_case", "ab_cd-ef", "ab_cd_ef");

etc

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a macro which I think does the job. At least codecov likes it.

}
23 changes: 23 additions & 0 deletions tests/test_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<RenameAllVariantsEnum>();
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"
);
});
}
6 changes: 6 additions & 0 deletions tests/ui/invalid_pyclass_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down
Loading