diff --git a/guide/src/function.md b/guide/src/function.md index b0d6c0b0..fe74e5df 100644 --- a/guide/src/function.md +++ b/guide/src/function.md @@ -73,39 +73,7 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python - `#[pyo3(text_signature = "...")]` - Sets the function signature visible in Python tooling (such as via [`inspect.signature`]). - - The example below creates a function `add` which has a signature describing two positional-only - arguments `a` and `b`. - - ```rust - use pyo3::prelude::*; - - /// This function adds two unsigned 64-bit integers. - #[pyfunction] - #[pyo3(text_signature = "(a, b, /)")] - fn add(a: u64, b: u64) -> u64 { - a + b - } - # - # fn main() -> PyResult<()> { - # Python::with_gil(|py| { - # let fun = pyo3::wrap_pyfunction!(add, py)?; - # - # let doc: String = fun.getattr("__doc__")?.extract()?; - # assert_eq!(doc, "This function adds two unsigned 64-bit integers."); - # - # let inspect = PyModule::import(py, "inspect")?.getattr("signature")?; - # let sig: String = inspect - # .call1((fun,))? - # .call_method0("__str__")? - # .extract()?; - # assert_eq!(sig, "(a, b, /)"); - # - # Ok(()) - # }) - # } - ``` + Overrides the PyO3-generated function signature visible in Python tooling (such as via [`inspect.signature`]). See the [corresponding topic in the Function Signatures subchapter](./function/signature.md#making-the-function-signature-available-to-python). - `#[pyo3(pass_module)]` @@ -161,47 +129,6 @@ The `#[pyo3]` attribute can be used on individual arguments to modify properties ## Advanced function patterns -### Making the function signature available to Python (old method) - -Alternatively, simply make sure the first line of your docstring is -formatted like in the following example. Please note that the newline after the -`--` is mandatory. The `/` signifies the end of positional-only arguments. - -`#[pyo3(text_signature)]` should be preferred, since it will override automatically -generated signatures when those are added in a future version of PyO3. - -```rust -# #![allow(dead_code)] -use pyo3::prelude::*; - -/// add(a, b, /) -/// -- -/// -/// This function adds two unsigned 64-bit integers. -#[pyfunction] -fn add(a: u64, b: u64) -> u64 { - a + b -} - -// a function with a signature but without docs. Both blank lines after the `--` are mandatory. - -/// sub(a, b, /) -/// -- -#[pyfunction] -fn sub(a: u64, b: u64) -> u64 { - a - b -} -``` - -When annotated like this, signatures are also correctly displayed in IPython. - -```text ->>> pyo3_test.add? -Signature: pyo3_test.add(a, b, /) -Docstring: This function adds two unsigned 64-bit integers. -Type: builtin_function_or_method -``` - ### Calling Python functions in Rust You can pass Python `def`'d functions and built-in functions to Rust functions [`PyFunction`] diff --git a/guide/src/function/signature.md b/guide/src/function/signature.md index 39e73308..651b4a3f 100644 --- a/guide/src/function/signature.md +++ b/guide/src/function/signature.md @@ -188,3 +188,138 @@ impl MyClass { } } ``` + +## Making the function signature available to Python + +The function signature is exposed to Python via the `__text_signature__` attribute. PyO3 automatically generates this for every `#[pyfunction]` and all `#[pymethods]` directly from the Rust function, taking into account any override done with the `#[pyo3(signature = (...))]` option. + +This automatic generation has some limitations, which may be improved in the future: +- It will not include the value of default arguments, replacing them all with `...`. (`.pyi` type stub files commonly also use `...` for all default arguments in the same way.) +- Nothing is generated for the `#[new]` method of a `#[pyclass]`. + +In cases where the automatically-generated signature needs adjusting, it can [be overridden](#overriding-the-generated-signature) using the `#[pyo3(text_signature)]` option.) + +The example below creates a function `add` which accepts two positional-only arguments `a` and `b`, where `b` has a default value of zero. + +```rust +use pyo3::prelude::*; + +/// This function adds two unsigned 64-bit integers. +#[pyfunction] +#[pyo3(signature = (a, b=0, /))] +fn add(a: u64, b: u64) -> u64 { + a + b +} +# +# fn main() -> PyResult<()> { +# Python::with_gil(|py| { +# let fun = pyo3::wrap_pyfunction!(add, py)?; +# +# let doc: String = fun.getattr("__doc__")?.extract()?; +# assert_eq!(doc, "This function adds two unsigned 64-bit integers."); +# +# let inspect = PyModule::import(py, "inspect")?.getattr("signature")?; +# let sig: String = inspect +# .call1((fun,))? +# .call_method0("__str__")? +# .extract()?; +# +# #[cfg(Py_3_8)] // on 3.7 the signature doesn't render b, upstream bug? +# assert_eq!(sig, "(a, b=Ellipsis, /)"); +# +# Ok(()) +# }) +# } +``` + +The following IPython output demonstrates how this generated signature will be seen from Python tooling: + +```text +>>> pyo3_test.add.__text_signature__ +'(a, b=..., /)' +>>> pyo3_test.add? +Signature: pyo3_test.add(a, b=Ellipsis, /) +Docstring: This function adds two unsigned 64-bit integers. +Type: builtin_function_or_method +``` + +### Overriding the generated signature + +The `#[pyo3(text_signature = "()")]` attribute can be used to override the default generated signature. + +In the snippet below, the text signature attribute is used to include the default value of `0` for the argument `b`, instead of the automatically-generated default value of `...`: + +```rust +use pyo3::prelude::*; + +/// This function adds two unsigned 64-bit integers. +#[pyfunction] +#[pyo3(signature = (a, b=0, /), text_signature = "(a, b=0, /)")] +fn add(a: u64, b: u64) -> u64 { + a + b +} +# +# fn main() -> PyResult<()> { +# Python::with_gil(|py| { +# let fun = pyo3::wrap_pyfunction!(add, py)?; +# +# let doc: String = fun.getattr("__doc__")?.extract()?; +# assert_eq!(doc, "This function adds two unsigned 64-bit integers."); +# +# let inspect = PyModule::import(py, "inspect")?.getattr("signature")?; +# let sig: String = inspect +# .call1((fun,))? +# .call_method0("__str__")? +# .extract()?; +# assert_eq!(sig, "(a, b=0, /)"); +# +# Ok(()) +# }) +# } +``` + +PyO3 will include the contents of the annotation unmodified as the `__text_signature`. Below shows how IPython will now present this (see the default value of 0 for b): + +```text +>>> pyo3_test.add.__text_signature__ +'(a, b=0, /)' +>>> pyo3_test.add? +Signature: pyo3_test.add(a, b=0, /) +Docstring: This function adds two unsigned 64-bit integers. +Type: builtin_function_or_method +``` + +If no signature is wanted at all, `#[pyo3(text_signature = None)]` will disable the built-in signature. The snippet below demonstrates use of this: + +```rust +use pyo3::prelude::*; + +/// This function adds two unsigned 64-bit integers. +#[pyfunction] +#[pyo3(signature = (a, b=0, /), text_signature = None)] +fn add(a: u64, b: u64) -> u64 { + a + b +} +# +# fn main() -> PyResult<()> { +# Python::with_gil(|py| { +# let fun = pyo3::wrap_pyfunction!(add, py)?; +# +# let doc: String = fun.getattr("__doc__")?.extract()?; +# assert_eq!(doc, "This function adds two unsigned 64-bit integers."); +# assert!(fun.getattr("__text_signature__")?.is_none()); +# +# Ok(()) +# }) +# } +``` + +Now the function's `__text_signature__` will be set to `None`, and IPython will not display any signature in the help: + +```text +>>> pyo3_test.add.__text_signature__ == None +True +>>> pyo3_test.add? +Docstring: This function adds two unsigned 64-bit integers. +Type: builtin_function_or_method +``` diff --git a/newsfragments/2784.changed.md b/newsfragments/2784.changed.md new file mode 100644 index 00000000..74c0428d --- /dev/null +++ b/newsfragments/2784.changed.md @@ -0,0 +1 @@ +Automatically generate `__text_signature__` for all Python functions created using `#[pyfunction]` and `#[pymethods]`. diff --git a/newsfragments/2827.added.md b/newsfragments/2827.added.md new file mode 100644 index 00000000..4bd084ae --- /dev/null +++ b/newsfragments/2827.added.md @@ -0,0 +1 @@ +Add `PyList::get_item_unchecked` for PyPy. diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 6235c7e1..247463ca 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -81,11 +81,46 @@ impl ToTokens for NameLitStr { } } +/// Text signatue can be either a literal string or opt-in/out +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TextSignatureAttributeValue { + Str(LitStr), + // `None` ident to disable automatic text signature generation + Disabled(Ident), +} + +impl Parse for TextSignatureAttributeValue { + fn parse(input: ParseStream<'_>) -> Result { + if let Ok(lit_str) = input.parse::() { + return Ok(TextSignatureAttributeValue::Str(lit_str)); + } + + let err_span = match input.parse::() { + Ok(ident) if ident == "None" => { + return Ok(TextSignatureAttributeValue::Disabled(ident)); + } + Ok(other_ident) => other_ident.span(), + Err(e) => e.span(), + }; + + Err(err_spanned!(err_span => "expected a string literal or `None`")) + } +} + +impl ToTokens for TextSignatureAttributeValue { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + TextSignatureAttributeValue::Str(s) => s.to_tokens(tokens), + TextSignatureAttributeValue::Disabled(b) => b.to_tokens(tokens), + } + } +} + pub type ExtendsAttribute = KeywordAttribute; pub type FreelistAttribute = KeywordAttribute>; pub type ModuleAttribute = KeywordAttribute; pub type NameAttribute = KeywordAttribute; -pub type TextSignatureAttribute = KeywordAttribute; +pub type TextSignatureAttribute = KeywordAttribute; impl Parse for KeywordAttribute { fn parse(input: ParseStream<'_>) -> Result { diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index 33d585e4..b79bb34b 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -5,7 +5,9 @@ use std::borrow::Cow; use crate::attributes::TextSignatureAttribute; use crate::deprecations::{Deprecation, Deprecations}; use crate::params::impl_arg_params; -use crate::pyfunction::{DeprecatedArgs, FunctionSignature, PyFunctionArgPyO3Attributes}; +use crate::pyfunction::{ + text_signature_or_auto, DeprecatedArgs, FunctionSignature, PyFunctionArgPyO3Attributes, +}; use crate::pyfunction::{PyFunctionOptions, SignatureAttribute}; use crate::utils::{self, PythonDoc}; use proc_macro2::{Span, TokenStream}; @@ -202,7 +204,7 @@ impl CallingConvention { pub fn from_signature(signature: &FunctionSignature<'_>) -> Self { if signature.python_signature.has_no_args() { Self::Noargs - } else if signature.python_signature.accepts_kwargs { + } else if signature.python_signature.kwargs.is_some() { // for functions that accept **kwargs, always prefer varargs Self::Varargs } else if cfg!(not(feature = "abi3")) { @@ -288,13 +290,6 @@ impl<'a> FnSpec<'a> { let ty = get_return_info(&sig.output); let python_name = python_name.as_ref().unwrap_or(name).unraw(); - let doc = utils::get_doc( - meth_attrs, - text_signature - .as_ref() - .map(|attr| (Cow::Borrowed(&python_name), attr)), - ); - let arguments: Vec<_> = if skip_first_arg { sig.inputs .iter_mut() @@ -320,6 +315,16 @@ impl<'a> FnSpec<'a> { FunctionSignature::from_arguments(arguments) }; + let text_signature_string = match &fn_type { + FnType::FnNew | FnType::Getter(_) | FnType::Setter(_) | FnType::ClassAttribute => None, + _ => text_signature_or_auto(text_signature.as_ref(), &signature, &fn_type), + }; + + let doc = utils::get_doc( + meth_attrs, + text_signature_string.map(|sig| (Cow::Borrowed(&python_name), sig)), + ); + let convention = fixed_convention.unwrap_or_else(|| CallingConvention::from_signature(&signature)); diff --git a/pyo3-macros-backend/src/params.rs b/pyo3-macros-backend/src/params.rs index a069d559..22d9d3e8 100644 --- a/pyo3-macros-backend/src/params.rs +++ b/pyo3-macros-backend/src/params.rs @@ -82,12 +82,12 @@ pub fn impl_arg_params( .map(|arg| impl_arg_param(arg, &mut option_pos, py, &args_array)) .collect::>()?; - let args_handler = if spec.signature.python_signature.accepts_varargs { + let args_handler = if spec.signature.python_signature.varargs.is_some() { quote! { _pyo3::impl_::extract_argument::TupleVarargs } } else { quote! { _pyo3::impl_::extract_argument::NoVarargs } }; - let kwargs_handler = if spec.signature.python_signature.accepts_kwargs { + let kwargs_handler = if spec.signature.python_signature.kwargs.is_some() { quote! { _pyo3::impl_::extract_argument::DictVarkeywords } } else { quote! { _pyo3::impl_::extract_argument::NoVarkeywords } diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index e15900b1..bfc9a5f1 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -9,6 +9,7 @@ use crate::attributes::{ use crate::deprecations::{Deprecation, Deprecations}; use crate::konst::{ConstAttributes, ConstSpec}; use crate::method::FnSpec; +use crate::pyfunction::text_signature_or_none; use crate::pyimpl::{gen_py_const, PyClassMethodsType}; use crate::pymethod::{ impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, MethodAndSlotDef, PropertyType, @@ -198,12 +199,10 @@ pub fn build_py_class( methods_type: PyClassMethodsType, ) -> syn::Result { args.options.take_pyo3_options(&mut class.attrs)?; + let text_signature_string = text_signature_or_none(args.options.text_signature.as_ref()); let doc = utils::get_doc( &class.attrs, - args.options - .text_signature - .as_ref() - .map(|attr| (get_class_python_name(&class.ident, &args), attr)), + text_signature_string.map(|s| (get_class_python_name(&class.ident, &args), s)), ); let krate = get_pyo3_crate(&args.options.krate); @@ -461,12 +460,11 @@ pub fn build_py_enum( bail_spanned!(enum_.brace_token.span => "#[pyclass] can't be used on enums without any variants"); } + let text_signature_string = text_signature_or_none(args.options.text_signature.as_ref()); + let doc = utils::get_doc( &enum_.attrs, - args.options - .text_signature - .as_ref() - .map(|attr| (get_class_python_name(&enum_.ident, &args), attr)), + text_signature_string.map(|s| (get_class_python_name(&enum_.ident, &args), s)), ); let enum_ = PyClassEnum::new(enum_)?; impl_enum(enum_, &args, doc, method_type) diff --git a/pyo3-macros-backend/src/pyfunction.rs b/pyo3-macros-backend/src/pyfunction.rs index 5e401818..6ce47409 100644 --- a/pyo3-macros-backend/src/pyfunction.rs +++ b/pyo3-macros-backend/src/pyfunction.rs @@ -5,10 +5,10 @@ use std::borrow::Cow; use crate::{ attributes::{ self, get_pyo3_options, take_attributes, take_pyo3_options, CrateAttribute, - FromPyWithAttribute, NameAttribute, TextSignatureAttribute, + FromPyWithAttribute, NameAttribute, TextSignatureAttribute, TextSignatureAttributeValue, }, deprecations::{Deprecation, Deprecations}, - method::{self, CallingConvention, FnArg}, + method::{self, CallingConvention, FnArg, FnType}, pymethod::check_generic, utils::{self, ensure_not_async_fn, get_pyo3_crate}, }; @@ -409,11 +409,11 @@ pub fn impl_wrap_pyfunction( let ty = method::get_return_info(&func.sig.output); + let text_signature_string = text_signature_or_auto(text_signature.as_ref(), &signature, &tp); + let doc = utils::get_doc( &func.attrs, - text_signature - .as_ref() - .map(|attr| (Cow::Borrowed(&python_name), attr)), + text_signature_string.map(|s| (Cow::Borrowed(&python_name), s)), ); let krate = get_pyo3_crate(&krate); @@ -480,3 +480,26 @@ fn type_is_pymodule(ty: &syn::Type) -> bool { } false } + +/// Helper to get a text signature string, or None if unset or disabled +pub(crate) fn text_signature_or_none( + text_signature: Option<&TextSignatureAttribute>, +) -> Option { + match text_signature.map(|attr| &attr.value) { + Some(TextSignatureAttributeValue::Str(s)) => Some(s.value()), + Some(TextSignatureAttributeValue::Disabled(_)) | None => None, + } +} + +/// Helper to get a text signature string, using automatic generation if unset, or None if disabled +pub(crate) fn text_signature_or_auto( + text_signature: Option<&TextSignatureAttribute>, + signature: &FunctionSignature<'_>, + fn_type: &FnType, +) -> Option { + match text_signature.map(|attr| &attr.value) { + Some(TextSignatureAttributeValue::Str(s)) => Some(s.value()), + None => Some(signature.text_signature(fn_type)), + Some(TextSignatureAttributeValue::Disabled(_)) => None, + } +} diff --git a/pyo3-macros-backend/src/pyfunction/signature.rs b/pyo3-macros-backend/src/pyfunction/signature.rs index 516d8031..0e4e3b2b 100644 --- a/pyo3-macros-backend/src/pyfunction/signature.rs +++ b/pyo3-macros-backend/src/pyfunction/signature.rs @@ -12,7 +12,7 @@ use syn::{ use crate::{ attributes::{kw, KeywordAttribute}, - method::FnArg, + method::{FnArg, FnType}, pyfunction::Argument, }; @@ -205,18 +205,18 @@ pub struct PythonSignature { pub positional_parameters: Vec, pub positional_only_parameters: usize, pub required_positional_parameters: usize, - pub accepts_varargs: bool, + pub varargs: Option, // Tuples of keyword name and whether it is required pub keyword_only_parameters: Vec<(String, bool)>, - pub accepts_kwargs: bool, + pub kwargs: Option, } impl PythonSignature { pub fn has_no_args(&self) -> bool { self.positional_parameters.is_empty() && self.keyword_only_parameters.is_empty() - && !self.accepts_varargs - && !self.accepts_kwargs + && self.varargs.is_none() + && self.kwargs.is_none() } } @@ -232,9 +232,9 @@ pub enum ParseState { /// Accepting positional parameters after '/' PositionalAfterPosargs, /// Accepting keyword-only parameters after '*' or '*args' - Keywords(Option), + Keywords, /// After `**kwargs` nothing is allowed - Done(String), + Done, } impl ParseState { @@ -257,12 +257,12 @@ impl ParseState { } Ok(()) } - ParseState::Keywords(_) => { + ParseState::Keywords => { signature.keyword_only_parameters.push((name, required)); Ok(()) } - ParseState::Done(s) => { - bail_spanned!(span => format!("no more arguments are allowed after `**{}`", s)) + ParseState::Done => { + bail_spanned!(span => format!("no more arguments are allowed after `**{}`", signature.kwargs.as_deref().unwrap_or(""))) } } } @@ -274,15 +274,15 @@ impl ParseState { ) -> syn::Result<()> { match self { ParseState::Positional | ParseState::PositionalAfterPosargs => { - signature.accepts_varargs = true; - *self = ParseState::Keywords(Some(varargs.ident.to_string())); + signature.varargs = Some(varargs.ident.to_string()); + *self = ParseState::Keywords; Ok(()) } - ParseState::Keywords(s) => { - bail_spanned!(varargs.span() => format!("`*{}` not allowed after `*{}`", varargs.ident, s.as_deref().unwrap_or(""))) + ParseState::Keywords => { + bail_spanned!(varargs.span() => format!("`*{}` not allowed after `*{}`", varargs.ident, signature.varargs.as_deref().unwrap_or(""))) } - ParseState::Done(s) => { - bail_spanned!(varargs.span() => format!("`*{}` not allowed after `**{}`", varargs.ident, s)) + ParseState::Done => { + bail_spanned!(varargs.span() => format!("`*{}` not allowed after `**{}`", varargs.ident, signature.kwargs.as_deref().unwrap_or(""))) } } } @@ -293,15 +293,13 @@ impl ParseState { kwargs: &SignatureItemKwargs, ) -> syn::Result<()> { match self { - ParseState::Positional - | ParseState::PositionalAfterPosargs - | ParseState::Keywords(_) => { - signature.accepts_kwargs = true; - *self = ParseState::Done(kwargs.ident.to_string()); + ParseState::Positional | ParseState::PositionalAfterPosargs | ParseState::Keywords => { + signature.kwargs = Some(kwargs.ident.to_string()); + *self = ParseState::Done; Ok(()) } - ParseState::Done(s) => { - bail_spanned!(kwargs.span() => format!("`**{}` not allowed after `**{}`", kwargs.ident, s)) + ParseState::Done => { + bail_spanned!(kwargs.span() => format!("`**{}` not allowed after `**{}`", kwargs.ident, signature.kwargs.as_deref().unwrap_or(""))) } } } @@ -320,26 +318,26 @@ impl ParseState { ParseState::PositionalAfterPosargs => { bail_spanned!(span => "`/` not allowed after `/`") } - ParseState::Keywords(s) => { - bail_spanned!(span => format!("`/` not allowed after `*{}`", s.as_deref().unwrap_or(""))) + ParseState::Keywords => { + bail_spanned!(span => format!("`/` not allowed after `*{}`", signature.varargs.as_deref().unwrap_or(""))) } - ParseState::Done(s) => { - bail_spanned!(span => format!("`/` not allowed after `**{}`", s)) + ParseState::Done => { + bail_spanned!(span => format!("`/` not allowed after `**{}`", signature.kwargs.as_deref().unwrap_or(""))) } } } - fn finish_pos_args(&mut self, span: Span) -> syn::Result<()> { + fn finish_pos_args(&mut self, signature: &PythonSignature, span: Span) -> syn::Result<()> { match self { ParseState::Positional | ParseState::PositionalAfterPosargs => { - *self = ParseState::Keywords(None); + *self = ParseState::Keywords; Ok(()) } - ParseState::Keywords(s) => { - bail_spanned!(span => format!("`*` not allowed after `*{}`", s.as_deref().unwrap_or(""))) + ParseState::Keywords => { + bail_spanned!(span => format!("`*` not allowed after `*{}`", signature.varargs.as_deref().unwrap_or(""))) } - ParseState::Done(s) => { - bail_spanned!(span => format!("`*` not allowed after `**{}`", s)) + ParseState::Done => { + bail_spanned!(span => format!("`*` not allowed after `**{}`", signature.kwargs.as_deref().unwrap_or(""))) } } } @@ -386,7 +384,9 @@ impl<'a> FunctionSignature<'a> { fn_arg.default = Some(default.clone()); } } - SignatureItem::VarargsSep(sep) => parse_state.finish_pos_args(sep.span())?, + SignatureItem::VarargsSep(sep) => { + parse_state.finish_pos_args(&python_signature, sep.span())? + } SignatureItem::Varargs(varargs) => { let fn_arg = next_argument_checked(&varargs.ident)?; fn_arg.is_varargs = true; @@ -423,8 +423,8 @@ impl<'a> FunctionSignature<'a> { mut arguments: Vec>, deprecated_args: DeprecatedArgs, ) -> syn::Result { - let mut accepts_varargs = false; - let mut accepts_kwargs = false; + let mut varargs = None; + let mut kwargs = None; let mut keyword_only_parameters = Vec::new(); fn first_n_argument_names(arguments: &[FnArg<'_>], count: usize) -> Vec { @@ -481,13 +481,21 @@ impl<'a> FunctionSignature<'a> { } Argument::PosOnlyArgsSeparator => {} Argument::VarArgsSeparator => {} - Argument::VarArgs(_) => { + Argument::VarArgs(path) => { fn_arg.is_varargs = true; - accepts_varargs = true; + if let Some(ident) = path.get_ident() { + varargs = Some(ident.to_string()); + } else { + bail_spanned!(path.span() => "expected ident for *args"); + }; } - Argument::KeywordArgs(_) => { + Argument::KeywordArgs(path) => { fn_arg.is_kwargs = true; - accepts_kwargs = true; + if let Some(ident) = path.get_ident() { + kwargs = Some(ident.to_string()); + } else { + bail_spanned!(path.span() => "expected ident for **kwargs"); + }; } } } else { @@ -513,9 +521,9 @@ impl<'a> FunctionSignature<'a> { positional_parameters, positional_only_parameters, required_positional_parameters, - accepts_varargs, + varargs, keyword_only_parameters, - accepts_kwargs, + kwargs, }, attribute: None, }) @@ -556,4 +564,80 @@ impl<'a> FunctionSignature<'a> { attribute: None, } } + + pub fn text_signature(&self, fn_type: &FnType) -> String { + // automatic text signature generation + let self_argument = match fn_type { + FnType::FnNew | FnType::Getter(_) | FnType::Setter(_) | FnType::ClassAttribute => { + unreachable!() + } + FnType::Fn(_) => Some("self"), + FnType::FnModule => Some("module"), + FnType::FnClass => Some("cls"), + FnType::FnStatic => None, + }; + + let mut output = String::new(); + output.push('('); + + if let Some(arg) = self_argument { + output.push('$'); + output.push_str(arg); + } + + let mut maybe_push_comma = { + let mut first = self_argument.is_none(); + move |output: &mut String| { + if !first { + output.push_str(", "); + } else { + first = false; + } + } + }; + + let py_sig = &self.python_signature; + + for (i, parameter) in py_sig.positional_parameters.iter().enumerate() { + maybe_push_comma(&mut output); + + output.push_str(parameter); + + if i >= py_sig.required_positional_parameters { + // has a default, just use ... for now + output.push_str("=..."); + } + + if py_sig.positional_only_parameters > 0 && i + 1 == py_sig.positional_only_parameters { + output.push_str(", /") + } + } + + if let Some(varargs) = &py_sig.varargs { + maybe_push_comma(&mut output); + output.push('*'); + output.push_str(varargs); + } else if !py_sig.keyword_only_parameters.is_empty() { + maybe_push_comma(&mut output); + output.push('*'); + } + + for (parameter, required) in &py_sig.keyword_only_parameters { + maybe_push_comma(&mut output); + output.push_str(parameter); + if !required { + // has a default, just use ... for now + output.push_str("=...") + } + } + + if let Some(kwargs) = &py_sig.kwargs { + maybe_push_comma(&mut output); + output.push_str("**"); + output.push_str(kwargs); + } + + output.push(')'); + output + } } diff --git a/pyo3-macros-backend/src/utils.rs b/pyo3-macros-backend/src/utils.rs index cbbe2b31..ad20e917 100644 --- a/pyo3-macros-backend/src/utils.rs +++ b/pyo3-macros-backend/src/utils.rs @@ -5,7 +5,7 @@ use proc_macro2::{Span, TokenStream}; use quote::ToTokens; use syn::{spanned::Spanned, Ident}; -use crate::attributes::{CrateAttribute, TextSignatureAttribute}; +use crate::attributes::CrateAttribute; /// Macro inspired by `anyhow::anyhow!` to create a compiler error with the given span. macro_rules! err_spanned { @@ -68,7 +68,7 @@ pub struct PythonDoc(TokenStream); /// e.g. concat!("...", "\n", "\0") pub fn get_doc( attrs: &[syn::Attribute], - text_signature: Option<(Cow<'_, Ident>, &TextSignatureAttribute)>, + text_signature: Option<(Cow<'_, Ident>, String)>, ) -> PythonDoc { let mut tokens = TokenStream::new(); let comma = syn::token::Comma(Span::call_site()); @@ -79,8 +79,7 @@ pub fn get_doc( syn::token::Bracket(Span::call_site()).surround(&mut tokens, |tokens| { if let Some((python_name, text_signature)) = text_signature { // create special doc string lines to set `__text_signature__` - let signature_lines = - format!("{}{}\n--\n\n", python_name, text_signature.value.value()); + let signature_lines = format!("{}{}\n--\n\n", python_name, text_signature); signature_lines.to_tokens(tokens); comma.to_tokens(tokens); } diff --git a/src/types/list.rs b/src/types/list.rs index d81e8153..2ef7bca8 100644 --- a/src/types/list.rs +++ b/src/types/list.rs @@ -142,7 +142,7 @@ impl PyList { /// # Safety /// /// Caller must verify that the index is within the bounds of the list. - #[cfg(not(any(Py_LIMITED_API, PyPy)))] + #[cfg(not(Py_LIMITED_API))] pub unsafe fn get_item_unchecked(&self, index: usize) -> &PyAny { let item = ffi::PyList_GET_ITEM(self.as_ptr(), index as Py_ssize_t); // PyList_GET_ITEM return borrowed ptr; must make owned for safety (see #890). diff --git a/tests/test_text_signature.rs b/tests/test_text_signature.rs index 51b69cce..63ed8161 100644 --- a/tests/test_text_signature.rs +++ b/tests/test_text_signature.rs @@ -1,6 +1,7 @@ #![cfg(feature = "macros")] use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; use pyo3::{types::PyType, wrap_pymodule, PyCell}; mod common; @@ -117,6 +118,179 @@ fn test_function() { }); } +#[test] +fn test_auto_test_signature_function() { + #[pyfunction] + fn my_function(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyfunction(pass_module)] + fn my_function_2(module: &PyModule, a: i32, b: Option, c: i32) { + let _ = (module, a, b, c); + } + + #[pyfunction(signature = (a, /, b = None, *, c = 5))] + fn my_function_3(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyfunction(signature = (a, /, b = None, *args, c, d=5, **kwargs))] + fn my_function_4( + a: i32, + b: Option, + args: &PyTuple, + c: i32, + d: i32, + kwargs: Option<&PyDict>, + ) { + let _ = (a, b, args, c, d, kwargs); + } + + Python::with_gil(|py| { + let f = wrap_pyfunction!(my_function)(py).unwrap(); + py_assert!(py, f, "f.__text_signature__ == '(a, b, c)'"); + + let f = wrap_pyfunction!(my_function_2)(py).unwrap(); + py_assert!(py, f, "f.__text_signature__ == '($module, a, b, c)'"); + + let f = wrap_pyfunction!(my_function_3)(py).unwrap(); + py_assert!(py, f, "f.__text_signature__ == '(a, /, b=..., *, c=...)'"); + + let f = wrap_pyfunction!(my_function_4)(py).unwrap(); + py_assert!( + py, + f, + "f.__text_signature__ == '(a, /, b=..., *args, c, d=..., **kwargs)'" + ); + }); +} + +#[test] +fn test_auto_test_signature_method() { + #[pyclass] + struct MyClass {} + + #[pymethods] + impl MyClass { + fn method(&self, a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyo3(signature = (a, /, b = None, *, c = 5))] + fn method_2(&self, a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyo3(signature = (a, /, b = None, *args, c, d=5, **kwargs))] + fn method_3( + &self, + a: i32, + b: Option, + args: &PyTuple, + c: i32, + d: i32, + kwargs: Option<&PyDict>, + ) { + let _ = (a, b, args, c, d, kwargs); + } + + #[staticmethod] + fn staticmethod(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[classmethod] + fn classmethod(cls: &PyType, a: i32, b: Option, c: i32) { + let _ = (cls, a, b, c); + } + } + + Python::with_gil(|py| { + let cls = py.get_type::(); + py_assert!( + py, + cls, + "cls.method.__text_signature__ == '($self, a, b, c)'" + ); + py_assert!( + py, + cls, + "cls.method_2.__text_signature__ == '($self, a, /, b=..., *, c=...)'" + ); + py_assert!( + py, + cls, + "cls.method_3.__text_signature__ == '($self, a, /, b=..., *args, c, d=..., **kwargs)'" + ); + py_assert!( + py, + cls, + "cls.staticmethod.__text_signature__ == '(a, b, c)'" + ); + py_assert!( + py, + cls, + "cls.classmethod.__text_signature__ == '($cls, a, b, c)'" + ); + }); +} + +#[test] +fn test_auto_test_signature_opt_out() { + #[pyfunction(text_signature = None)] + fn my_function(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyfunction(signature = (a, /, b = None, *, c = 5), text_signature = None)] + fn my_function_2(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyclass] + struct MyClass {} + + #[pymethods] + impl MyClass { + #[pyo3(text_signature = None)] + fn method(&self, a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[pyo3(signature = (a, /, b = None, *, c = 5), text_signature = None)] + fn method_2(&self, a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[staticmethod] + #[pyo3(text_signature = None)] + fn staticmethod(a: i32, b: Option, c: i32) { + let _ = (a, b, c); + } + + #[classmethod] + #[pyo3(text_signature = None)] + fn classmethod(cls: &PyType, a: i32, b: Option, c: i32) { + let _ = (cls, a, b, c); + } + } + + Python::with_gil(|py| { + let f = wrap_pyfunction!(my_function)(py).unwrap(); + py_assert!(py, f, "f.__text_signature__ == None"); + + let f = wrap_pyfunction!(my_function_2)(py).unwrap(); + py_assert!(py, f, "f.__text_signature__ == None"); + + let cls = py.get_type::(); + py_assert!(py, cls, "cls.method.__text_signature__ == None"); + py_assert!(py, cls, "cls.method_2.__text_signature__ == None"); + py_assert!(py, cls, "cls.staticmethod.__text_signature__ == None"); + py_assert!(py, cls, "cls.classmethod.__text_signature__ == None"); + }); +} + #[test] fn test_pyfn() { #[pymodule] diff --git a/tests/ui/invalid_pymethods.rs b/tests/ui/invalid_pymethods.rs index 26cd2bf6..a6690b05 100644 --- a/tests/ui/invalid_pymethods.rs +++ b/tests/ui/invalid_pymethods.rs @@ -79,6 +79,19 @@ impl MyClass { fn text_signature_on_classattr() {} } +#[pymethods] +impl MyClass { + #[pyo3(text_signature = 1)] + fn invalid_text_signature() {} +} + +#[pymethods] +impl MyClass { + #[pyo3(text_signature = "()")] + #[pyo3(text_signature = None)] + fn duplicate_text_signature() {} +} + #[pymethods] impl MyClass { #[getter(x)] diff --git a/tests/ui/invalid_pymethods.stderr b/tests/ui/invalid_pymethods.stderr index 36eab673..e660b52c 100644 --- a/tests/ui/invalid_pymethods.stderr +++ b/tests/ui/invalid_pymethods.stderr @@ -64,73 +64,85 @@ error: `text_signature` not allowed with `classattr` 78 | #[pyo3(text_signature = "()")] | ^^^^^^^^^^^^^^ -error: `signature` not allowed with `getter` - --> tests/ui/invalid_pymethods.rs:85:12 +error: expected a string literal or `None` + --> tests/ui/invalid_pymethods.rs:84:30 | -85 | #[pyo3(signature = ())] +84 | #[pyo3(text_signature = 1)] + | ^ + +error: `text_signature` may only be specified once + --> tests/ui/invalid_pymethods.rs:91:12 + | +91 | #[pyo3(text_signature = None)] + | ^^^^^^^^^^^^^^ + +error: `signature` not allowed with `getter` + --> tests/ui/invalid_pymethods.rs:98:12 + | +98 | #[pyo3(signature = ())] | ^^^^^^^^^ error: `signature` not allowed with `setter` - --> tests/ui/invalid_pymethods.rs:92:12 - | -92 | #[pyo3(signature = ())] - | ^^^^^^^^^ + --> tests/ui/invalid_pymethods.rs:105:12 + | +105 | #[pyo3(signature = ())] + | ^^^^^^^^^ error: `signature` not allowed with `classattr` - --> tests/ui/invalid_pymethods.rs:99:12 - | -99 | #[pyo3(signature = ())] - | ^^^^^^^^^ + --> tests/ui/invalid_pymethods.rs:112:12 + | +112 | #[pyo3(signature = ())] + | ^^^^^^^^^ error: cannot specify a second method type - --> tests/ui/invalid_pymethods.rs:106:7 + --> tests/ui/invalid_pymethods.rs:119:7 | -106 | #[staticmethod] +119 | #[staticmethod] | ^^^^^^^^^^^^ error: Python functions cannot have generic type parameters - --> tests/ui/invalid_pymethods.rs:112:23 + --> tests/ui/invalid_pymethods.rs:125:23 | -112 | fn generic_method(value: T) {} +125 | fn generic_method(value: T) {} | ^ error: Python functions cannot have `impl Trait` arguments - --> tests/ui/invalid_pymethods.rs:117:48 + --> tests/ui/invalid_pymethods.rs:130:48 | -117 | fn impl_trait_method_first_arg(impl_trait: impl AsRef) {} +130 | fn impl_trait_method_first_arg(impl_trait: impl AsRef) {} | ^^^^ error: Python functions cannot have `impl Trait` arguments - --> tests/ui/invalid_pymethods.rs:122:56 + --> tests/ui/invalid_pymethods.rs:135:56 | -122 | fn impl_trait_method_second_arg(&self, impl_trait: impl AsRef) {} +135 | fn impl_trait_method_second_arg(&self, impl_trait: impl AsRef) {} | ^^^^ error: `async fn` is not yet supported for Python functions. Additional crates such as `pyo3-asyncio` can be used to integrate async Rust and Python. For more information, see https://github.com/PyO3/pyo3/issues/1632 - --> tests/ui/invalid_pymethods.rs:127:5 + --> tests/ui/invalid_pymethods.rs:140:5 | -127 | async fn async_method(&self) {} +140 | async fn async_method(&self) {} | ^^^^^ error: `pass_module` cannot be used on Python methods - --> tests/ui/invalid_pymethods.rs:132:12 + --> tests/ui/invalid_pymethods.rs:145:12 | -132 | #[pyo3(pass_module)] +145 | #[pyo3(pass_module)] | ^^^^^^^^^^^ error: Python objects are shared, so 'self' cannot be moved out of the Python interpreter. Try `&self`, `&mut self, `slf: PyRef<'_, Self>` or `slf: PyRefMut<'_, Self>`. - --> tests/ui/invalid_pymethods.rs:138:29 + --> tests/ui/invalid_pymethods.rs:151:29 | -138 | fn method_self_by_value(self) {} +151 | fn method_self_by_value(self) {} | ^^^^ error[E0592]: duplicate definitions with name `__pymethod___new____` - --> tests/ui/invalid_pymethods.rs:143:1 + --> tests/ui/invalid_pymethods.rs:156:1 | -143 | #[pymethods] +156 | #[pymethods] | ^^^^^^^^^^^^ | | | duplicate definitions for `__pymethod___new____` @@ -139,9 +151,9 @@ error[E0592]: duplicate definitions with name `__pymethod___new____` = note: this error originates in the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0592]: duplicate definitions with name `__pymethod_func__` - --> tests/ui/invalid_pymethods.rs:158:1 + --> tests/ui/invalid_pymethods.rs:171:1 | -158 | #[pymethods] +171 | #[pymethods] | ^^^^^^^^^^^^ | | | duplicate definitions for `__pymethod_func__`