From 3f2fab32eca9f999d58fa1a7595bafb4336a29c5 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Sun, 31 Aug 2025 19:55:03 +0100 Subject: [PATCH 1/2] deprecate `pyfn` attribute --- guide/src/function.md | 59 ++-------- newsfragments/5384.changed.md | 1 + pyo3-macros-backend/src/module.rs | 4 + tests/test_module.rs | 179 +++++++++++++++++++----------- tests/test_text_signature.rs | 1 + 5 files changed, 129 insertions(+), 115 deletions(-) create mode 100644 newsfragments/5384.changed.md diff --git a/guide/src/function.md b/guide/src/function.md index b129480559c..2a3d8f84240 100644 --- a/guide/src/function.md +++ b/guide/src/function.md @@ -26,7 +26,6 @@ This chapter of the guide explains full usage of the `#[pyfunction]` attribute. - [`#[pyo3(warn(message = "...", category = ...))]`](#warn) - [Per-argument options](#per-argument-options) - [Advanced function patterns](#advanced-function-patterns) -- [`#[pyfn]` shorthand](#pyfn-shorthand) There are also additional sections on the following topics: @@ -95,11 +94,11 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python ``` - `#[pyo3(warn(message = "...", category = ...))]` - This option is used to display a warning when the function is used in Python. It is equivalent to [`warnings.warn(message, category)`](https://docs.python.org/3/library/warnings.html#warnings.warn). - The `message` parameter is a string that will be displayed when the function is called, and the `category` parameter is optional and has to be a subclass of [`Warning`](https://docs.python.org/3/library/exceptions.html#Warning). + This option is used to display a warning when the function is used in Python. It is equivalent to [`warnings.warn(message, category)`](https://docs.python.org/3/library/warnings.html#warnings.warn). + The `message` parameter is a string that will be displayed when the function is called, and the `category` parameter is optional and has to be a subclass of [`Warning`](https://docs.python.org/3/library/exceptions.html#Warning). When the `category` parameter is not provided, the warning will be defaulted to [`UserWarning`](https://docs.python.org/3/library/exceptions.html#UserWarning). - > Note: when used with `#[pymethods]`, this attribute does not work with `#[classattr]` nor `__traverse__` magic method. + > Note: when used with `#[pymethods]`, this attribute does not work with `#[classattr]` nor `__traverse__` magic method. The following are examples of using the `#[pyo3(warn)]` attribute: @@ -110,20 +109,20 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python mod raising_warning_fn { use pyo3::prelude::pyfunction; use pyo3::exceptions::PyFutureWarning; - + #[pyfunction] #[pyo3(warn(message = "This is a warning message"))] fn function_with_warning() -> usize { 42 } - + #[pyfunction] #[pyo3(warn(message = "This function is warning with FutureWarning", category = PyFutureWarning))] fn function_with_warning_and_custom_category() -> usize { 42 } } - + # use pyo3::exceptions::{PyFutureWarning, PyUserWarning}; # use pyo3::types::{IntoPyDict, PyList}; # use pyo3::PyTypeInfo; @@ -142,7 +141,7 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python # .unwrap(); # Ok(()) # } - # + # # macro_rules! assert_warnings { # ($py:expr, $body:expr, [$(($category:ty, $message:literal)),+] $(,)? ) => { # catch_warning($py, |list| { @@ -159,7 +158,7 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python # }).unwrap(); # }; # } - # + # # Python::attach(|py| { # assert_warnings!( # py, @@ -181,7 +180,7 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python # }); ``` - When the functions are called as the following, warnings will be displayed. + When the functions are called as the following, warnings will be displayed. ```python import warnings @@ -197,7 +196,7 @@ The `#[pyo3]` attribute can be used to modify properties of the generated Python UserWarning: This is a warning message FutureWarning: This function is warning with FutureWarning ``` - + ## Per-argument options The `#[pyo3]` attribute can be used on individual arguments to modify properties of them in the generated function. It can take any combination of the following options: @@ -268,42 +267,4 @@ arguments from the input `PyObject`s. The `wrap_pyfunction` macro can be used to directly get a `Bound` given a `#[pyfunction]` and a `Bound`: `wrap_pyfunction!(rust_fun, module)`. -## `#[pyfn]` shorthand - -There is a shorthand to `#[pyfunction]` and `wrap_pymodule!`: the function can be placed inside the module definition and -annotated with `#[pyfn]`. To simplify PyO3, it is expected that `#[pyfn]` may be removed in a future release (See [#694](https://github.com/PyO3/pyo3/issues/694)). - -An example of `#[pyfn]` is below: - -```rust,no_run -use pyo3::prelude::*; - -#[pymodule] -fn my_extension(m: &Bound<'_, PyModule>) -> PyResult<()> { - #[pyfn(m)] - fn double(x: usize) -> usize { - x * 2 - } - - Ok(()) -} -``` - -`#[pyfn(m)]` is just syntactic sugar for `#[pyfunction]`, and takes all the same options -documented in the rest of this chapter. The code above is expanded to the following: - -```rust,no_run -use pyo3::prelude::*; - -#[pymodule] -fn my_extension(m: &Bound<'_, PyModule>) -> PyResult<()> { - #[pyfunction] - fn double(x: usize) -> usize { - x * 2 - } - - m.add_function(wrap_pyfunction!(double, m)?) -} -``` - [`inspect.signature`]: https://docs.python.org/3/library/inspect.html#inspect.signature diff --git a/newsfragments/5384.changed.md b/newsfragments/5384.changed.md new file mode 100644 index 00000000000..fe7377d7de8 --- /dev/null +++ b/newsfragments/5384.changed.md @@ -0,0 +1 @@ +Deprecate `pyfn` attribute. diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 1888f564df5..f78a957d77d 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -567,6 +567,10 @@ fn process_functions_in_module(options: &PyModuleOptions, func: &mut syn::ItemFn { use #pyo3_path::types::PyModuleMethods; #module_name.add_function(#pyo3_path::wrap_pyfunction!(#name, #module_name.as_borrowed())?)?; + #[deprecated(note = "`pyfn` will be removed in a future PyO3 version, use declarative `#[pymodule]` with `mod` instead")] + #[allow(dead_code)] + const PYFN_ATTRIBUTE: () = (); + const _: () = PYFN_ATTRIBUTE; } }; stmts.extend(statements); diff --git a/tests/test_module.rs b/tests/test_module.rs index 5e6244420e0..20174f9baeb 100644 --- a/tests/test_module.rs +++ b/tests/test_module.rs @@ -5,7 +5,6 @@ use pyo3::prelude::*; use pyo3::py_run; use pyo3::types::PyString; use pyo3::types::{IntoPyDict, PyDict, PyTuple}; -use pyo3::BoundObject; use pyo3_ffi::c_str; mod test_utils; @@ -35,41 +34,44 @@ fn double(x: usize) -> usize { x * 2 } -/// This module is implemented in Rust. -#[pymodule(gil_used = false)] -fn module_with_functions(m: &Bound<'_, PyModule>) -> PyResult<()> { - #[pyfn(m)] - #[pyo3(name = "no_parameters")] - fn function_with_name() -> usize { - 42 - } +#[test] +fn test_module_with_functions() { + use pyo3::wrap_pymodule; - #[pyfn(m)] - #[pyo3(pass_module)] - fn with_module<'py>(module: &Bound<'py, PyModule>) -> PyResult> { - module.name() - } + /// This module is implemented in Rust. + #[pymodule(gil_used = false)] + mod module_with_functions { + use super::*; - #[pyfn(m)] - fn double_value(v: &ValueClass) -> usize { - v.value * 2 - } + #[pymodule_export] + use super::{AnonClass, ValueClass}; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - - m.add("foo", "bar")?; + #[pymodule_init] + fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add("foo", "bar")?; + m.add_function(wrap_pyfunction!(double, m)?)?; + m.add("also_double", wrap_pyfunction!(double, m)?)?; + Ok(()) + } - m.add_function(wrap_pyfunction!(double, m)?)?; - m.add("also_double", wrap_pyfunction!(double, m)?)?; + #[pyfunction] + #[pyo3(name = "no_parameters")] + fn function_with_name() -> usize { + 42 + } - Ok(()) -} + #[pyfunction] + #[pyo3(pass_module)] + fn with_module<'py>(module: &Bound<'py, PyModule>) -> PyResult> { + module.name() + } -#[test] -fn test_module_with_functions() { - use pyo3::wrap_pymodule; + #[pyfunction] + fn double_value(v: &ValueClass) -> usize { + v.value * 2 + } + } Python::attach(|py| { let d = [( @@ -118,6 +120,87 @@ fn test_module_with_functions() { }); } +#[test] +#[allow(deprecated)] +fn test_module_with_pyfn() { + use pyo3::wrap_pymodule; + + /// This module is implemented in Rust. + #[pymodule(gil_used = false)] + fn module_with_pyfn(m: &Bound<'_, PyModule>) -> PyResult<()> { + #[pyfn(m)] + #[pyo3(name = "no_parameters")] + fn function_with_name() -> usize { + 42 + } + + #[pyfn(m)] + #[pyo3(pass_module)] + fn with_module<'py>(module: &Bound<'py, PyModule>) -> PyResult> { + module.name() + } + + #[pyfn(m)] + fn double_value(v: &ValueClass) -> usize { + v.value * 2 + } + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + m.add("foo", "bar")?; + + m.add_function(wrap_pyfunction!(double, m)?)?; + m.add("also_double", wrap_pyfunction!(double, m)?)?; + + Ok(()) + } + + Python::attach(|py| { + let d = [("module_with_pyfn", wrap_pymodule!(module_with_pyfn)(py))] + .into_py_dict(py) + .unwrap(); + + py_assert!( + py, + *d, + "module_with_pyfn.__doc__ == 'This module is implemented in Rust.'" + ); + py_assert!(py, *d, "module_with_pyfn.no_parameters() == 42"); + py_assert!(py, *d, "module_with_pyfn.foo == 'bar'"); + py_assert!(py, *d, "module_with_pyfn.AnonClass != None"); + py_assert!(py, *d, "module_with_pyfn.LocatedClass != None"); + py_assert!( + py, + *d, + "module_with_pyfn.LocatedClass.__module__ == 'module'" + ); + py_assert!(py, *d, "module_with_pyfn.double(3) == 6"); + py_assert!( + py, + *d, + "module_with_pyfn.double.__doc__ == 'Doubles the given value'" + ); + py_assert!(py, *d, "module_with_pyfn.also_double(3) == 6"); + py_assert!( + py, + *d, + "module_with_pyfn.also_double.__doc__ == 'Doubles the given value'" + ); + py_assert!( + py, + *d, + "module_with_pyfn.double_value(module_with_pyfn.ValueClass(1)) == 2" + ); + py_assert!( + py, + *d, + "module_with_pyfn.with_module() == 'module_with_pyfn'" + ); + }); +} + /// This module uses a legacy two-argument module function. #[pymodule] fn module_with_explicit_py_arg(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -319,42 +402,6 @@ fn test_module_nesting() { }); } -// Test that argument parsing specification works for pyfunctions - -#[pyfunction(signature = (a=5, *args))] -fn ext_vararg_fn(py: Python<'_>, a: i32, args: &Bound<'_, PyTuple>) -> PyResult> { - [ - a.into_pyobject(py)?.into_any().into_bound(), - args.as_any().clone(), - ] - .into_pyobject(py) - .map(Bound::unbind) -} - -#[pymodule] -fn vararg_module(m: &Bound<'_, PyModule>) -> PyResult<()> { - #[pyfn(m, signature = (a=5, *args))] - fn int_vararg_fn(py: Python<'_>, a: i32, args: &Bound<'_, PyTuple>) -> PyResult> { - ext_vararg_fn(py, a, args) - } - - m.add_function(wrap_pyfunction!(ext_vararg_fn, m)?).unwrap(); - Ok(()) -} - -#[test] -fn test_vararg_module() { - Python::attach(|py| { - let m = pyo3::wrap_pymodule!(vararg_module)(py); - - py_assert!(py, m, "m.ext_vararg_fn() == [5, ()]"); - py_assert!(py, m, "m.ext_vararg_fn(1, 2) == [1, (2,)]"); - - py_assert!(py, m, "m.int_vararg_fn() == [5, ()]"); - py_assert!(py, m, "m.int_vararg_fn(1, 2) == [1, (2,)]"); - }); -} - #[test] fn test_module_with_constant() { // Regression test for #1102 diff --git a/tests/test_text_signature.rs b/tests/test_text_signature.rs index 88057b05b6a..b4815d692a1 100644 --- a/tests/test_text_signature.rs +++ b/tests/test_text_signature.rs @@ -333,6 +333,7 @@ fn test_auto_test_signature_opt_out() { } #[test] +#[allow(deprecated)] fn test_pyfn() { #[pymodule] fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> { From a76a506adb73bed200726bf85b1a4c592bb413db Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Thu, 4 Sep 2025 11:12:40 +0100 Subject: [PATCH 2/2] add UI test --- pyo3-macros-backend/src/module.rs | 13 +++++++------ tests/test_compile_error.rs | 1 + tests/ui/deprecated_pyfn.rs | 13 +++++++++++++ tests/ui/deprecated_pyfn.stderr | 11 +++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 tests/ui/deprecated_pyfn.rs create mode 100644 tests/ui/deprecated_pyfn.stderr diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index f78a957d77d..dc504949e2f 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -558,11 +558,12 @@ fn process_functions_in_module(options: &PyModuleOptions, func: &mut syn::ItemFn for mut stmt in func.block.stmts.drain(..) { if let syn::Stmt::Item(Item::Fn(func)) = &mut stmt { - if let Some(pyfn_args) = get_pyfn_attr(&mut func.attrs)? { + if let Some((pyfn_span, pyfn_args)) = get_pyfn_attr(&mut func.attrs)? { let module_name = pyfn_args.modname; let wrapped_function = impl_wrap_pyfunction(func, pyfn_args.options)?; let name = &func.sig.ident; - let statements: Vec = syn::parse_quote! { + let statements: Vec = syn::parse_quote_spanned! { + pyfn_span => #wrapped_function { use #pyo3_path::types::PyModuleMethods; @@ -611,8 +612,8 @@ impl Parse for PyFnArgs { } /// Extracts the data from the #[pyfn(...)] attribute of a function -fn get_pyfn_attr(attrs: &mut Vec) -> syn::Result> { - let mut pyfn_args: Option = None; +fn get_pyfn_attr(attrs: &mut Vec) -> syn::Result> { + let mut pyfn_args: Option<(Span, PyFnArgs)> = None; take_attributes(attrs, |attr| { if attr.path().is_ident("pyfn") { @@ -620,14 +621,14 @@ fn get_pyfn_attr(attrs: &mut Vec) -> syn::Result "`#[pyfn] may only be specified once" ); - pyfn_args = Some(attr.parse_args()?); + pyfn_args = Some((attr.path().span(), attr.parse_args()?)); Ok(true) } else { Ok(false) } })?; - if let Some(pyfn_args) = &mut pyfn_args { + if let Some((_, pyfn_args)) = &mut pyfn_args { pyfn_args .options .add_attributes(take_pyo3_options(attrs)?)?; diff --git a/tests/test_compile_error.rs b/tests/test_compile_error.rs index c3b8570d6d3..94c118bb650 100644 --- a/tests/test_compile_error.rs +++ b/tests/test_compile_error.rs @@ -5,6 +5,7 @@ fn test_compile_errors() { let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/deprecated_pyfn.rs"); t.compile_fail("tests/ui/invalid_property_args.rs"); t.compile_fail("tests/ui/invalid_proto_pymethods.rs"); t.compile_fail("tests/ui/invalid_pyclass_args.rs"); diff --git a/tests/ui/deprecated_pyfn.rs b/tests/ui/deprecated_pyfn.rs new file mode 100644 index 00000000000..1c5edbafd31 --- /dev/null +++ b/tests/ui/deprecated_pyfn.rs @@ -0,0 +1,13 @@ +#![deny(deprecated)] + +use pyo3::prelude::*; + +#[pymodule] +fn module_with_pyfn(m: &Bound<'_, PyModule>) -> PyResult<()> { + #[pyfn(m)] + fn foo() {} + + Ok(()) +} + +fn main() {} diff --git a/tests/ui/deprecated_pyfn.stderr b/tests/ui/deprecated_pyfn.stderr new file mode 100644 index 00000000000..c5dd0b3b4ac --- /dev/null +++ b/tests/ui/deprecated_pyfn.stderr @@ -0,0 +1,11 @@ +error: use of deprecated constant `module_with_pyfn::PYFN_ATTRIBUTE`: `pyfn` will be removed in a future PyO3 version, use declarative `#[pymodule]` with `mod` instead + --> tests/ui/deprecated_pyfn.rs:7:7 + | +7 | #[pyfn(m)] + | ^^^^ + | +note: the lint level is defined here + --> tests/ui/deprecated_pyfn.rs:1:9 + | +1 | #![deny(deprecated)] + | ^^^^^^^^^^