diff --git a/Cargo.toml b/Cargo.toml index 7ec2abc44f9..1b6100e8bae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ widestring = "0.5.1" pyo3-build-config = { path = "pyo3-build-config", version = "0.18.1", features = ["resolve-config"] } [features] -default = ["macros"] +default = ["macros", "pyo3"] # Enables pyo3::inspect module and additional type information on FromPyObject # and IntoPy traits @@ -110,8 +110,12 @@ full = [ "eyre", "anyhow", "experimental-inspect", + "pyo3", ] +# This makes `#[cfg_attr(feature = "pyo3", pyclass)]` in our own tests work, it has no other function +pyo3 = [] + [[bench]] name = "bench_call" harness = false diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index 2198c3792e9..7ecc10c4c72 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -14,6 +14,7 @@ - [Basic object customization](class/object.md) - [Emulating numeric types](class/numeric.md) - [Emulating callable objects](class/call.md) + - [Optional bindings](class/optional_bindings.md) - [Type conversions](conversions.md) - [Mapping of Rust types to Python types](conversions/tables.md)] - [Conversion traits](conversions/traits.md)] diff --git a/guide/src/class/optional_bindings.md b/guide/src/class/optional_bindings.md new file mode 100644 index 00000000000..24c669b7296 --- /dev/null +++ b/guide/src/class/optional_bindings.md @@ -0,0 +1,66 @@ +# Optional bindings + +You might want to write a library the is usable both in pure Rust and as a Python library. For that, PyO3 supports wrapping attributes in `#[cfg_attr(feature = "pyo3", ...)]` (the feature name unfortunately has to be hardcoded, so the feature must be named `pyo3`). This does not only apply to classes and their methods but also to e.g. `#[pyfunction]`. + +Make pyo3 optional in Cargo.toml: + +```toml +[lib] +# cdylib for the python module, rlib for the rust crate +crate-type = ["cdylib", "rlib"] + +[dependencies] +pyo3 = { version = "0.14", features = ["extension-module", "abi3"], optional = true } +``` + +If you're using maturin, also set `pyo3` as a default feature in pyproject.toml, so `maturin build` will work as well as `cargo build`: + +```toml +[tool.maturin] +features = ["pyo3"] +``` + +Implementing a `Number` again, but this time making all attributes and the module function optional: + +```rust +use pyo3::prelude::*; + +#[cfg_attr(feature = "pyo3", pyclass)] +struct Number(i32); + +#[cfg_attr(feature = "pyo3", pymethods)] +impl Number { + #[cfg_attr(feature = "pyo3", classattr)] + const SMALLEST_PRIME: i32 = 2; + + #[cfg_attr(feature = "pyo3", new)] + fn new(value: i32) -> Self { + Self(value) + } + + /// Computes the [Greatest common divisor](https://en.wikipedia.org/wiki/Greatest_common_divisor) of two numbers + #[cfg_attr(feature = "pyo3", pyo3(name = "gcd"))] + fn greatest_common_divisor(&self, other: &Self) -> Self { + let mut a = self.0; + let mut b = other.0; + while a != b { + if a > b { + a -= b + } else { + b -= a + } + } + + Self::new(a) + } +} + +#[cfg(feature = "pyo3")] // We don't want that function at all in a rust library +#[pymodule] +fn my_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} +``` + +Now you have a library that you can use both normally in rust without any python dependency and as a python library. \ No newline at end of file diff --git a/newsfragments/2786.added.md b/newsfragments/2786.added.md new file mode 100644 index 00000000000..68e48683e0f --- /dev/null +++ b/newsfragments/2786.added.md @@ -0,0 +1 @@ +Add support for wrapping attributes in `#[cfg_attr(feature = "pyo3", ...)]`, so you can add pyo3 as a purely optional feature to otherwise normal rust libraries. See the "Optional bindings" chapter in the guide for more info and usage examples. \ No newline at end of file diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 247463ca7e6..8ea0ff3e05d 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -1,11 +1,14 @@ use proc_macro2::TokenStream; use quote::ToTokens; +use std::iter::FromIterator; +use syn::parse::Parser; +use syn::punctuated::{IntoPairs, Pair}; use syn::{ parse::{Parse, ParseStream}, punctuated::Punctuated, spanned::Spanned, token::Comma, - Attribute, Expr, ExprPath, Ident, LitStr, Path, Result, Token, + Attribute, Expr, ExprPath, Ident, Lit, LitStr, Meta, MetaList, NestedMeta, Path, Result, Token, }; pub mod kw { @@ -144,22 +147,16 @@ pub type FromPyWithAttribute = KeywordAttribute>; -pub fn get_pyo3_options(attr: &syn::Attribute) -> Result>> { - if is_attribute_ident(attr, "pyo3") { +/// We can either have `#[pyo3(...)]` or `#[cfg_attr(feature = "pyo3", pyo3(...))]`, +/// with a comma separated list of options parsed into `T` inside +pub fn get_pyo3_options(attr: &Attribute) -> Result>> { + if attr.path.is_ident("pyo3") { attr.parse_args_with(Punctuated::parse_terminated).map(Some) } else { Ok(None) } } -pub fn is_attribute_ident(attr: &syn::Attribute, name: &str) -> bool { - if let Some(path_segment) = attr.path.segments.last() { - attr.path.segments.len() == 1 && path_segment.ident == name - } else { - false - } -} - /// Takes attributes from an attribute vector. /// /// For each attribute in `attrs`, `extractor` is called. If `extractor` returns `Ok(true)`, then @@ -169,12 +166,12 @@ pub fn is_attribute_ident(attr: &syn::Attribute, name: &str) -> bool { /// (In `retain`, returning `true` keeps the element, here it removes it.) pub fn take_attributes( attrs: &mut Vec, - mut extractor: impl FnMut(&Attribute) -> Result, + mut extractor: impl FnMut(&mut Attribute) -> Result, ) -> Result<()> { *attrs = attrs .drain(..) - .filter_map(|attr| { - extractor(&attr) + .filter_map(|mut attr| { + extractor(&mut attr) .map(move |attribute_handled| if attribute_handled { None } else { Some(attr) }) .transpose() }) @@ -182,15 +179,144 @@ pub fn take_attributes( Ok(()) } -pub fn take_pyo3_options(attrs: &mut Vec) -> Result> { +pub fn take_pyo3_options(attrs: &mut Vec) -> Result> { let mut out = Vec::new(); - take_attributes(attrs, |attr| { - if let Some(options) = get_pyo3_options(attr)? { + let mut new_attrs = Vec::new(); + + for mut attr in attrs.drain(..) { + let parse_attr = |meta, _attributes: &Attribute| { + if let Meta::List(meta_list) = meta { + if meta_list.path.is_ident("pyo3") { + let parsed = Punctuated::<_, Token![,]>::parse_terminated + .parse2(meta_list.nested.to_token_stream())?; + out.extend(parsed.into_iter()); + return Ok(true); + } + } + Ok(false) + }; + if attr.path.is_ident("cfg_attr") { + if let Ok(mut meta) = attr.parse_meta() { + if handle_cfg_feature_pyo3(&mut attr, &mut meta, parse_attr)? { + continue; + } + } + } + if let Some(options) = get_pyo3_options(&attr)? { out.extend(options.into_iter()); - Ok(true) + continue; } else { - Ok(false) + new_attrs.push(attr) } - })?; + } + + *attrs = new_attrs; Ok(out) } + +/// Look for #[cfg_attr(feature = "pyo3", ...)] +/// ^^^^^^^^ ^^^^^^^ ^ ^^^^^^ +fn is_cfg_feature_pyo3( + list: &MetaList, + keep: &mut Vec>, + iter: &mut IntoPairs, +) -> bool { + // #[cfg_attr(feature = "pyo3", ...)] + // ^^^^^^^^ + if list.path.is_ident("cfg_attr") { + // #[cfg_attr(feature = "pyo3", ...)] + // ------- ^ ------ + if let Some(pair) = iter.next() { + let pair_tuple = pair.into_tuple(); + if let (NestedMeta::Meta(Meta::NameValue(name_value)), _) = &pair_tuple { + // #[cfg_attr(feature = "pyo3", ...)] + // ^^^^^^^ + if name_value.path.is_ident("feature") { + if let Lit::Str(lit_str) = &name_value.lit { + // #[cfg_attr(feature = "pyo3", ...)] + // ^^^^^^ + if lit_str.value() == "pyo3" { + // We want to keep the none-pyo3 pairs intact + keep.push(Pair::new(pair_tuple.0, pair_tuple.1)); + return true; + } + } + } + } + keep.push(Pair::new(pair_tuple.0, pair_tuple.1)); + } + } + false +} + +/// Handle #[cfg_attr(feature = "pyo3", ...)] +/// +/// Returns whether the attribute was completely handled and can be discarded (because there were +/// blocks in cfg_attr tail that weren't handled) +/// +/// Attributes are icky: by default, we get an `Attribute` where all the real data is hidden in a +/// `TokenStream` member. Most of the attribute parsing are therefore custom `Parse` impls. We can +/// also ask syn to parse the attribute into `Meta`, which is essentially an attribute AST, which +/// also some code uses. +/// +/// With `cfg_attr` we can additionally have multiple attributes rolled into one behind a gate. So +/// we have to parse and look for `cfg_attr(feature = "pyo3",`, then segment the parts behind it. +/// For each one we have to check whether it parses and also keep those where it doesn't parse for +/// subsequent proc macros (or rustc) to parse. The least bad option for this seems to parsing into +/// `Meta`, checking for `cfg_attr(feature = "pyo3",`, then splitting and letting the caller process +/// each attribute, including calling `.to_token_stream()` and then using `Parse` if necessary +/// (as e.g. [take_pyo3_options] does). +pub fn handle_cfg_feature_pyo3( + mut attr: &mut Attribute, + meta: &mut Meta, + // Return true if handled + mut parse_attr: impl FnMut(Meta, &Attribute) -> Result, +) -> Result { + if let Meta::List(list) = meta { + // These are the parts of the attr `parse_attr` told us we didn't parse and we should + // keep for subsequent proc macros + let mut keep = Vec::new(); + // handrolled drain function, because `Punctuated` doesn't have one. + // We keep the comma around so what we do is lossless (keeping the spans) + let mut drain = list.nested.clone().into_pairs(); + // Look for #[cfg_attr(feature = "pyo3", ...)] + if !is_cfg_feature_pyo3(list, &mut keep, &mut drain) { + // No match? Put the meta back we just swapped out, we don't actually want to drain + list.nested = Punctuated::from_iter(keep.into_iter().chain(drain)); + return Ok(false); + } + + // #[cfg_attr(feature = "pyo3", staticmethod, pair(name = "ferris"))] + // ^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^ + for nested_attr in drain { + if let NestedMeta::Meta(meta) = nested_attr.value() { + if !parse_attr(meta.clone(), &attr)? { + keep.push(nested_attr) + } + } + } + + // The one that is always there is the condition in the cfg_attr (we put it in in + // is_cfg_feature_pyo3) + assert!(!keep.is_empty()); + // If it's exactly 1, we handled all attributes + if keep.len() > 1 { + list.nested = Punctuated::from_iter(keep); + + // Keep only the attributes we didn't parse. + // I couldn't find any method to just get the `attr.tokens` part again but with + // parentheses so here's token stream editing + let mut tokens = TokenStream::new(); + list.paren_token.surround(&mut tokens, |inner| { + inner.extend(list.nested.to_token_stream()) + }); + attr.tokens = tokens; + + return Ok(false); + } + + // We handled this entire attribute, next + return Ok(true); + } + Ok(false) +} diff --git a/pyo3-macros-backend/src/konst.rs b/pyo3-macros-backend/src/konst.rs index 30cffe89dd2..5dda8e59309 100644 --- a/pyo3-macros-backend/src/konst.rs +++ b/pyo3-macros-backend/src/konst.rs @@ -1,16 +1,19 @@ use std::borrow::Cow; +use crate::attributes::handle_cfg_feature_pyo3; use crate::{ - attributes::{self, get_pyo3_options, is_attribute_ident, take_attributes, NameAttribute}, + attributes::{self, NameAttribute}, deprecations::Deprecations, }; use proc_macro2::{Ident, TokenStream}; -use quote::quote; +use quote::{quote, ToTokens}; +use syn::parse::Parser; +use syn::punctuated::Punctuated; use syn::{ ext::IdentExt, parse::{Parse, ParseStream}, spanned::Spanned, - Result, + Attribute, Meta, Result, Token, }; pub struct ConstSpec { @@ -56,32 +59,35 @@ impl Parse for PyO3ConstAttribute { } impl ConstAttributes { - pub fn from_attrs(attrs: &mut Vec) -> syn::Result { + pub fn from_attrs(attrs: &mut Vec) -> Result { let mut attributes = ConstAttributes { is_class_attr: false, name: None, deprecations: Deprecations::new(), }; - take_attributes(attrs, |attr| { - if is_attribute_ident(attr, "classattr") { - ensure_spanned!( - attr.tokens.is_empty(), - attr.span() => "`#[classattr]` does not take any arguments" - ); - attributes.is_class_attr = true; - Ok(true) - } else if let Some(pyo3_attributes) = get_pyo3_options(attr)? { - for pyo3_attr in pyo3_attributes { - match pyo3_attr { - PyO3ConstAttribute::Name(name) => attributes.set_name(name)?, + let mut new_attrs = Vec::new(); + + for mut attr in attrs.drain(..) { + let parse_attr = |meta, _attr: &Attribute| parse_attribute(&mut attributes, &meta); + if attr.path.is_ident("cfg_attr") + || attr.path.is_ident("classattr") + || attr.path.is_ident("pyo3") + { + if let Ok(mut meta) = attr.parse_meta() { + if handle_cfg_feature_pyo3(&mut attr, &mut meta, parse_attr)? { + continue; + } + + if parse_attribute(&mut attributes, &meta)? { + continue; } } - Ok(true) - } else { - Ok(false) } - })?; + new_attrs.push(attr) + } + + *attrs = new_attrs; Ok(attributes) } @@ -95,3 +101,66 @@ impl ConstAttributes { Ok(()) } } + +/// Returns whether this attribute was parsed and should be discarded +fn parse_attribute(mut attributes: &mut ConstAttributes, meta: &Meta) -> Result { + if let Meta::Path(path) = meta { + if path.is_ident("classattr") { + attributes.is_class_attr = true; + return Ok(true); + } + } else if let Meta::List(meta_list) = meta { + if meta_list.path.is_ident("classattr") { + return Err(syn::Error::new( + meta_list.nested.span(), + "`#[classattr]` does not take any arguments", + )); + } + } + + if let Meta::List(meta_list) = meta { + if meta_list.path.is_ident("pyo3") { + if let Ok(parsed) = Punctuated::<_, Token![,]>::parse_terminated + .parse2(meta_list.nested.to_token_stream()) + { + for pyo3_attr in parsed { + match pyo3_attr { + PyO3ConstAttribute::Name(name) => attributes.set_name(name)?, + } + } + return Ok(true); + } + } + } + + Ok(false) +} + +#[cfg(test)] +mod test { + use crate::konst::ConstAttributes; + use quote::ToTokens; + use syn::{parse_quote, ItemConst}; + + #[test] + fn test_const_attributes() { + let inputs: Vec<(ItemConst, usize)> = vec![ + (parse_quote! { #[classattr] const MAX: u16 = 65535; }, 0), + ( + parse_quote! { #[cfg_attr(feature = "pyo3", classattr)] const MAX: u16 = 65535; }, + 0, + ), + ( + parse_quote! { #[cfg_attr(feature = "pyo3", other, classattr, still_other)] const MAX: u16 = 65535; }, + 1, + ), + ]; + for (mut konst, attrs_remaining) in inputs { + let actual = ConstAttributes::from_attrs(&mut konst.attrs).unwrap(); + assert!(actual.is_class_attr); + assert!(actual.name.is_none()); + assert!(actual.deprecations.to_token_stream().is_empty()); + assert_eq!(konst.attrs.len(), attrs_remaining); + } + } +} diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index 9b40ac007e2..0c8d032e484 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -1,6 +1,6 @@ // Copyright (c) 2017-present PyO3 Project and Contributors -use crate::attributes::TextSignatureAttribute; +use crate::attributes::{handle_cfg_feature_pyo3, TextSignatureAttribute}; use crate::deprecations::{Deprecation, Deprecations}; use crate::params::impl_arg_params; use crate::pyfunction::{DeprecatedArgs, FunctionSignature, PyFunctionArgPyO3Attributes}; @@ -11,7 +11,7 @@ use quote::ToTokens; use quote::{quote, quote_spanned}; use syn::ext::IdentExt; use syn::spanned::Spanned; -use syn::Result; +use syn::{Attribute, Meta, Result}; #[derive(Clone, Debug)] pub struct FnArg<'a> { @@ -275,7 +275,11 @@ impl<'a> FnSpec<'a> { ty: fn_type_attr, deprecated_args, mut python_name, - } = parse_method_attributes(meth_attrs, name.map(|name| name.value.0), &mut deprecations)?; + } = MethodAttributes::parse_method_attributes( + meth_attrs, + name.map(|name| name.value.0), + &mut deprecations, + )?; let (fn_type, skip_first_arg, fixed_convention) = Self::parse_fn_type(sig, fn_type_attr, &mut python_name)?; @@ -584,27 +588,58 @@ struct MethodAttributes { python_name: Option, } -fn parse_method_attributes( - attrs: &mut Vec, - mut python_name: Option, - deprecations: &mut Deprecations, -) -> Result { - let mut new_attrs = Vec::new(); - let mut deprecated_args = None; - let mut ty: Option = None; - - macro_rules! set_ty { - ($new_ty:expr, $ident:expr) => { - ensure_spanned!( - ty.replace($new_ty).is_none(), - $ident.span() => "cannot specify a second method type" - ); +impl MethodAttributes { + fn parse_method_attributes( + attrs: &mut Vec, + python_name: Option, + deprecations: &mut Deprecations, + ) -> Result { + let mut method_attributes = Self { + ty: None, + deprecated_args: None, + python_name, }; + let mut new_attrs = Vec::new(); + + for mut attr in attrs.drain(..) { + if let Ok(mut meta) = attr.parse_meta() { + let parse_attr = |meta, attr: &Attribute| { + method_attributes.parse_attribute(deprecations, meta, attr) + }; + if handle_cfg_feature_pyo3(&mut attr, &mut meta, parse_attr)? { + continue; + } + + if method_attributes.parse_attribute(deprecations, meta.clone(), &attr)? { + continue; + } + } + new_attrs.push(attr) + } + + *attrs = new_attrs; + + Ok(method_attributes) } - for attr in attrs.drain(..) { - match attr.parse_meta() { - Ok(syn::Meta::Path(name)) => { + /// Returns whether the attribute was consumed as a pyo3 attribute + fn parse_attribute( + &mut self, + deprecations: &mut Deprecations, + meta: Meta, + attr: &Attribute, + ) -> Result { + macro_rules! set_ty { + ($new_ty:expr, $ident:expr) => { + ensure_spanned!( + self.ty.replace($new_ty).is_none(), + $ident.span() => "cannot specify a second method type" + ); + }; + } + + match meta { + Meta::Path(name) => { if name.is_ident("new") || name.is_ident("__new__") { set_ty!(MethodTypeAttribute::New, name); } else if name.is_ident("init") || name.is_ident("__init__") { @@ -629,22 +664,22 @@ fn parse_method_attributes( set_ty!(MethodTypeAttribute::Getter, name); } } else { - new_attrs.push(attr) + return Ok(false); } } - Ok(syn::Meta::List(syn::MetaList { + Meta::List(syn::MetaList { path, mut nested, .. - })) => { + }) => { if path.is_ident("new") { set_ty!(MethodTypeAttribute::New, path); } else if path.is_ident("init") { bail_spanned!(path.span() => "#[init] is disabled since PyO3 0.9.0"); } else if path.is_ident("call") { ensure_spanned!( - python_name.is_none(), - python_name.span() => "`name` may not be used with `#[call]`" + self.python_name.is_none(), + self.python_name.span() => "`name` may not be used with `#[call]`" ); - python_name = Some(syn::Ident::new("__call__", Span::call_site())); + self.python_name = Some(syn::Ident::new("__call__", Span::call_site())); } else if path.is_ident("setter") || path.is_ident("getter") { if let syn::AttrStyle::Inner(_) = attr.style { bail_spanned!( @@ -663,11 +698,11 @@ fn parse_method_attributes( }; ensure_spanned!( - python_name.is_none(), - python_name.span() => "`name` may only be specified once" + self.python_name.is_none(), + self.python_name.span() => "`name` may only be specified once" ); - python_name = match nested.pop().unwrap().into_value() { + self.python_name = match nested.pop().unwrap().into_value() { syn::NestedMeta::Meta(syn::Meta::Path(w)) if w.segments.len() == 1 => { Some(w.segments[0].ident.clone()) } @@ -689,26 +724,19 @@ fn parse_method_attributes( }; } else if path.is_ident("args") { ensure_spanned!( - deprecated_args.is_none(), + self.deprecated_args.is_none(), nested.span() => "args may only be specified once" ); deprecations.push(Deprecation::PyMethodArgsAttribute, path.span()); - deprecated_args = Some(DeprecatedArgs::from_meta(&nested)?); + self.deprecated_args = Some(DeprecatedArgs::from_meta(&nested)?); } else { - new_attrs.push(attr) + return Ok(false); } } - Ok(syn::Meta::NameValue(_)) | Err(_) => new_attrs.push(attr), + Meta::NameValue(_) => return Ok(false), } + Ok(true) } - - *attrs = new_attrs; - - Ok(MethodAttributes { - ty, - deprecated_args, - python_name, - }) } const IMPL_TRAIT_ERR: &str = "Python functions cannot have `impl Trait` arguments"; diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index cb2fda00c0f..7746e50df2b 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -2,9 +2,7 @@ //! Code generation for the function that initializes a python module and adds classes and function. use crate::{ - attributes::{ - self, is_attribute_ident, take_attributes, take_pyo3_options, CrateAttribute, NameAttribute, - }, + attributes::{self, take_attributes, take_pyo3_options, CrateAttribute, NameAttribute}, pyfunction::{impl_wrap_pyfunction, PyFunctionOptions}, utils::{get_pyo3_crate, PythonDoc}, }; @@ -166,7 +164,7 @@ fn get_pyfn_attr(attrs: &mut Vec) -> syn::Result = None; take_attributes(attrs, |attr| { - if is_attribute_ident(attr, "pyfn") { + if attr.path.is_ident("pyfn") { ensure_spanned!( pyfn_args.is_none(), attr.span() => "`#[pyfn] may only be specified once" diff --git a/pyo3-macros-backend/src/pyfunction.rs b/pyo3-macros-backend/src/pyfunction.rs index d0a5b6c1f18..297bbad77cb 100644 --- a/pyo3-macros-backend/src/pyfunction.rs +++ b/pyo3-macros-backend/src/pyfunction.rs @@ -503,3 +503,32 @@ pub(crate) fn text_signature_or_auto( Some(TextSignatureAttributeValue::Disabled(_)) => None, } } + +#[cfg(test)] +mod test { + use crate::PyFunctionOptions; + use syn::{parse_quote, ImplItemMethod}; + + /// Ensure it leaves the other attr be + #[test] + fn test_py_function_options() { + let mut meth: ImplItemMethod = parse_quote! { + #[cfg_attr(feature = "pyo3", classattr)] #[pyo3(name = "bar")] fn foo() -> i32 { 5 } + }; + let expected_attrs = vec![meth.attrs[0].clone()]; + let options = PyFunctionOptions::from_attrs(&mut meth.attrs).unwrap(); + assert_eq!(options.name.unwrap().value.0.to_string(), "bar"); + assert_eq!(meth.attrs, expected_attrs); + } + + /// Ensure the nested parsing works + #[test] + fn test_py_function_options_pyo3_in_cfg_attr() { + let mut meth: ImplItemMethod = parse_quote! { + #[cfg_attr(feature = "pyo3", pyo3(name = "bar"))] fn foo() -> i32 { 5 } + }; + let options = PyFunctionOptions::from_attrs(&mut meth.attrs).unwrap(); + assert_eq!(options.name.unwrap().value.0.to_string(), "bar"); + assert_eq!(meth.attrs, Vec::new()); + } +} diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs index a93fe70f859..f5b0797caf7 100644 --- a/pyo3-macros-backend/src/pymethod.rs +++ b/pyo3-macros-backend/src/pymethod.rs @@ -1378,3 +1378,27 @@ impl ToTokens for TokenGenerator { self.0().to_tokens(tokens) } } + +#[cfg(test)] +mod test { + use crate::method::FnType; + use crate::pymethod::PyMethod; + use syn::{parse_quote, ImplItemMethod}; + + /// Ensure it parses identical whether wrapped or not + #[test] + fn test_method_attributes() { + let inputs: Vec = vec![ + parse_quote! {#[cfg_attr(feature = "pyo3", classattr)] fn foo() -> i32 { 5 } }, + parse_quote! {#[classattr] fn foo() -> i32 { 5 } }, + ]; + for mut method in inputs { + let parsed = + PyMethod::parse(&mut method.sig, &mut method.attrs, Default::default()).unwrap(); + assert!(matches!(parsed.spec.tp, FnType::ClassAttribute)); + assert_eq!(parsed.spec.name.to_string(), "foo"); + assert_eq!(parsed.spec.python_name.to_string(), "foo"); + assert!(method.attrs.is_empty()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 940f061e6b3..bf5483a5977 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -474,6 +474,7 @@ pub mod doc_test { "guide/src/class/object.md" => guide_class_object, "guide/src/class/numeric.md" => guide_class_numeric, "guide/src/class/protocols.md" => guide_class_protocols_md, + "guide/src/class/optional_bindings.md" => optional_bindings_md, "guide/src/conversions.md" => guide_conversions_md, "guide/src/conversions/tables.md" => guide_conversions_tables_md, "guide/src/conversions/traits.md" => guide_conversions_traits_md, diff --git a/tests/test_compile_error.rs b/tests/test_compile_error.rs index ffef87ee2fe..d2b411b443c 100644 --- a/tests/test_compile_error.rs +++ b/tests/test_compile_error.rs @@ -32,6 +32,7 @@ fn test_compile_errors() { fn _test_compile_errors() { let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/invalid_cfg_attr_pyo3.rs"); t.compile_fail("tests/ui/invalid_macro_args.rs"); t.compile_fail("tests/ui/invalid_need_module_arg_position.rs"); t.compile_fail("tests/ui/invalid_property_args.rs"); diff --git a/tests/test_optional_bindings.rs b/tests/test_optional_bindings.rs new file mode 100644 index 00000000000..1f0fe7f9d18 --- /dev/null +++ b/tests/test_optional_bindings.rs @@ -0,0 +1,81 @@ +//! This is test_class_attributes.rs wrapped in cfg_attr +#![cfg(all(feature = "macros", feature = "pyo3"))] + +use pyo3::prelude::*; + +mod common; + +#[cfg_attr(feature = "pyo3", pyfunction)] +fn double(x: usize) -> usize { + x * 2 +} + +#[cfg_attr(feature = "pyo3", pyclass)] +struct Foo { + #[cfg_attr(feature = "pyo3", pyo3(get))] + x: i32, +} + +#[cfg_attr(feature = "pyo3", pyclass)] +struct Bar { + #[cfg_attr(feature = "pyo3", pyo3(get))] + x: i32, +} + +#[cfg_attr(feature = "pyo3", pymethods)] +impl Foo { + #[cfg_attr(feature = "pyo3", classattr)] + const MY_CONST: &'static str = "foobar"; + + // Here we can combine the two lines into one due to cfg_attr + #[cfg_attr(feature = "pyo3", classattr, pyo3(name = "RENAMED_CONST"))] + const MY_CONST_2: &'static str = "foobar_2"; + + #[cfg_attr(feature = "pyo3", classattr)] + fn a() -> i32 { + 5 + } + + // Here we don't merge them + #[cfg_attr(feature = "pyo3", classattr)] + #[cfg_attr(feature = "pyo3", pyo3(name = "B"))] + fn b() -> String { + "bar".to_string() + } + + #[cfg_attr(feature = "pyo3", classattr)] + fn bar() -> Bar { + Bar { x: 2 } + } + + #[cfg_attr(feature = "pyo3", classattr)] + fn a_foo() -> Foo { + Foo { x: 1 } + } + + #[cfg_attr(feature = "pyo3", classattr)] + fn a_foo_with_py(py: Python<'_>) -> Py { + Py::new(py, Foo { x: 1 }).unwrap() + } +} + +#[cfg_attr(feature = "pyo3", pymodule)] +fn optional_bindings_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(double, m)?)?; + Ok(()) +} + +#[test] +fn optional_bindings() { + Python::with_gil(|py| { + let foo_obj = py.get_type::(); + py_assert!(py, foo_obj, "foo_obj.MY_CONST == 'foobar'"); + py_assert!(py, foo_obj, "foo_obj.RENAMED_CONST == 'foobar_2'"); + py_assert!(py, foo_obj, "foo_obj.a == 5"); + py_assert!(py, foo_obj, "foo_obj.B == 'bar'"); + py_assert!(py, foo_obj, "foo_obj.a_foo.x == 1"); + py_assert!(py, foo_obj, "foo_obj.a_foo_with_py.x == 1"); + }); +} diff --git a/tests/ui/invalid_cfg_attr_pyo3.rs b/tests/ui/invalid_cfg_attr_pyo3.rs new file mode 100644 index 00000000000..0e65f8bd476 --- /dev/null +++ b/tests/ui/invalid_cfg_attr_pyo3.rs @@ -0,0 +1,13 @@ +#![cfg(feature = "macros")] + +use pyo3::prelude::*; + +#[cfg_attr(feature = "pyo3", pyclass)] +struct Foo { + #[cfg_attr(feature = "pyo3", pyo3(get), fail)] + x: i32, +} + +fn main() { + Foo { x: 1 }; +} diff --git a/tests/ui/invalid_cfg_attr_pyo3.stderr b/tests/ui/invalid_cfg_attr_pyo3.stderr new file mode 100644 index 00000000000..339300776f5 --- /dev/null +++ b/tests/ui/invalid_cfg_attr_pyo3.stderr @@ -0,0 +1,5 @@ +error: cannot find attribute `fail` in this scope + --> tests/ui/invalid_cfg_attr_pyo3.rs:7:45 + | +7 | #[cfg_attr(feature = "pyo3", pyo3(get), fail)] + | ^^^^ diff --git a/tests/ui/invalid_pymethods.stderr b/tests/ui/invalid_pymethods.stderr index e660b52c1a3..cc6042d815c 100644 --- a/tests/ui/invalid_pymethods.stderr +++ b/tests/ui/invalid_pymethods.stderr @@ -5,10 +5,10 @@ error: #[classattr] can only have one argument (of type pyo3::Python) | ^^^ error: `#[classattr]` does not take any arguments - --> tests/ui/invalid_pymethods.rs:14:5 + --> tests/ui/invalid_pymethods.rs:14:17 | 14 | #[classattr(foobar)] - | ^ + | ^^^^^^ error: static method needs #[staticmethod] attribute --> tests/ui/invalid_pymethods.rs:20:5