diff --git a/newsfragments/3588.added.md b/newsfragments/3588.added.md new file mode 100644 index 00000000000..20c9a3c7bd4 --- /dev/null +++ b/newsfragments/3588.added.md @@ -0,0 +1 @@ +Add `__name__`/`__qualname__` attributes to `Coroutine`, as well as a Python warning when the coroutine is dropped without having been awaited \ No newline at end of file diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index cacd07c3a4f..a3e96c25358 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -455,13 +455,27 @@ impl<'a> FnSpec<'a> { let func_name = &self.name; let rust_call = |args: Vec| { - let call = quote! { function(#self_arg #(#args),*) }; - let wrapped_call = if self.asyncness.is_some() { - quote! { _pyo3::PyResult::Ok(_pyo3::impl_::wrap::wrap_future(#call)) } - } else { - quotes::ok_wrap(call) - }; - quotes::map_result_into_ptr(wrapped_call) + let mut call = quote! { function(#self_arg #(#args),*) }; + if self.asyncness.is_some() { + let python_name = &self.python_name; + let qualname = match cls { + Some(cls) => quote! { + _pyo3::impl_::coroutine::method_coroutine_qualname::<#cls>(py, stringify!(#python_name)) + }, + None => quote! { + _pyo3::impl_::coroutine::coroutine_qualname(py, py.from_borrowed_ptr_or_opt::<_pyo3::types::PyModule>(_slf), stringify!(#python_name)) + }, + }; + call = quote! {{ + let future = #call; + _pyo3::impl_::coroutine::new_coroutine( + _pyo3::types::PyString::new(py, stringify!(#python_name)).into(), + #qualname, + async move { _pyo3::impl_::wrap::OkWrap::wrap(future.await) } + ) + }}; + } + quotes::map_result_into_ptr(quotes::ok_wrap(call)) }; let rust_name = if let Some(cls) = cls { diff --git a/src/coroutine.rs b/src/coroutine.rs index 564262f3bf4..a9eea678efa 100644 --- a/src/coroutine.rs +++ b/src/coroutine.rs @@ -14,10 +14,10 @@ use pyo3_macros::{pyclass, pymethods}; use crate::{ coroutine::waker::AsyncioWaker, - exceptions::{PyRuntimeError, PyStopIteration}, + exceptions::{PyAttributeError, PyRuntimeError, PyRuntimeWarning, PyStopIteration}, panic::PanicException, pyclass::IterNextOutput, - types::PyIterator, + types::{PyIterator, PyString}, IntoPy, Py, PyAny, PyErr, PyObject, PyResult, Python, }; @@ -30,6 +30,8 @@ type FutureOutput = Result, Box>; /// Python coroutine wrapping a [`Future`]. #[pyclass(crate = "crate")] pub struct Coroutine { + name: Option>, + qualname: Option>, future: Option + Send>>>, waker: Option>, } @@ -41,18 +43,25 @@ impl Coroutine { /// (should always be `None` anyway). /// /// `Coroutine `throw` drop the wrapped future and reraise the exception passed - pub(crate) fn from_future(future: F) -> Self + pub(crate) fn new( + name: Option>, + mut qualname: Option>, + future: F, + ) -> Self where F: Future> + Send + 'static, T: IntoPy, - PyErr: From, + E: Into, { let wrap = async move { - let obj = future.await?; + let obj = future.await.map_err(Into::into)?; // SAFETY: GIL is acquired when future is polled (see `Coroutine::poll`) Ok(obj.into_py(unsafe { Python::assume_gil_acquired() })) }; + qualname = qualname.or_else(|| name.clone()); Self { + name, + qualname, future: Some(Box::pin(panic::AssertUnwindSafe(wrap).catch_unwind())), waker: None, } @@ -113,6 +122,20 @@ pub(crate) fn iter_result(result: IterNextOutput) -> PyResul #[pymethods(crate = "crate")] impl Coroutine { + #[getter] + fn __name__(&self) -> PyResult> { + self.name + .clone() + .ok_or_else(|| PyAttributeError::new_err("__name__")) + } + + #[getter] + fn __qualname__(&self) -> PyResult> { + self.qualname + .clone() + .ok_or_else(|| PyAttributeError::new_err("__qualname__")) + } + fn send(&mut self, py: Python<'_>, _value: &PyAny) -> PyResult { iter_result(self.poll(py, None)?) } @@ -135,3 +158,21 @@ impl Coroutine { self.poll(py, None) } } + +impl Drop for Coroutine { + fn drop(&mut self) { + if self.future.is_some() { + Python::with_gil(|gil| { + let qualname = self + .qualname + .as_ref() + .map_or(Ok(""), |n| n.as_ref(gil).to_str()) + .unwrap(); + let message = format!("coroutine {} was never awaited", qualname); + PyErr::warn(gil, gil.get_type::(), &message, 2) + .expect("warning error"); + self.poll(gil, None).expect("coroutine close error"); + }) + } + } +} diff --git a/src/impl_.rs b/src/impl_.rs index 118d62d9dbc..77f9ff4ea1f 100644 --- a/src/impl_.rs +++ b/src/impl_.rs @@ -6,6 +6,8 @@ //! APIs may may change at any time without documentation in the CHANGELOG and without //! breaking semver guarantees. +#[cfg(feature = "macros")] +pub mod coroutine; pub mod deprecations; pub mod extract_argument; pub mod freelist; diff --git a/src/impl_/coroutine.rs b/src/impl_/coroutine.rs new file mode 100644 index 00000000000..03dae9abfa9 --- /dev/null +++ b/src/impl_/coroutine.rs @@ -0,0 +1,36 @@ +use std::future::Future; + +use crate::{ + coroutine::Coroutine, + types::{PyModule, PyString}, + IntoPy, PyClass, PyErr, PyObject, Python, +}; + +pub fn new_coroutine(name: &PyString, qualname: &PyString, future: F) -> Coroutine +where + F: Future> + Send + 'static, + T: IntoPy, + E: Into, +{ + Coroutine::new(Some(name.into()), Some(qualname.into()), future) +} + +pub fn coroutine_qualname<'py>( + py: Python<'py>, + module: Option<&PyModule>, + name: &str, +) -> &'py PyString { + match module.and_then(|m| m.name().ok()) { + Some(module) => PyString::new(py, &format!("{}.{}", module, name)), + None => PyString::new(py, name), + } +} + +pub fn method_coroutine_qualname<'py, T: PyClass>(py: Python<'py>, name: &str) -> &'py PyString { + let class = T::NAME; + let qualname = match T::MODULE { + Some(module) => format!("{}.{}.{}", module, class, name), + None => format!("{}.{}", class, name), + }; + PyString::new(py, &qualname) +} diff --git a/src/impl_/wrap.rs b/src/impl_/wrap.rs index b41055b2863..2110d8411d0 100644 --- a/src/impl_/wrap.rs +++ b/src/impl_/wrap.rs @@ -67,20 +67,6 @@ pub fn map_result_into_py>( result.map(|err| err.into_py(py)) } -/// Used to wrap the result of async `#[pyfunction]` and `#[pymethods]`. -#[cfg(feature = "macros")] -pub fn wrap_future(future: F) -> crate::coroutine::Coroutine -where - F: std::future::Future + Send + 'static, - R: OkWrap, - T: IntoPy, - crate::PyErr: From, -{ - crate::coroutine::Coroutine::from_future::<_, T, crate::PyErr>(async move { - OkWrap::wrap(future.await).map_err(Into::into) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/test_coroutine.rs b/tests/test_coroutine.rs index 7c195e63733..c993ef69a14 100644 --- a/tests/test_coroutine.rs +++ b/tests/test_coroutine.rs @@ -1,8 +1,10 @@ #![cfg(feature = "macros")] #![cfg(not(target_arch = "wasm32"))] +use std::ops::Deref; use std::{task::Poll, thread, time::Duration}; use futures::{channel::oneshot, future::poll_fn}; +use pyo3::types::{IntoPyDict, PyType}; use pyo3::{prelude::*, py_run}; #[path = "../src/tests/common.rs"] @@ -30,6 +32,69 @@ fn noop_coroutine() { }) } +#[test] +fn test_coroutine_qualname() { + #[pyfunction] + async fn my_fn() {} + #[pyclass] + struct MyClass; + #[pymethods] + impl MyClass { + #[new] + fn new() -> Self { + Self + } + // TODO use &self when possible + async fn my_method(_self: Py) {} + #[classmethod] + async fn my_classmethod(_cls: Py) {} + #[staticmethod] + async fn my_staticmethod() {} + } + #[pyclass(module = "my_module")] + struct MyClassWithModule; + #[pymethods] + impl MyClassWithModule { + #[new] + fn new() -> Self { + Self + } + // TODO use &self when possible + async fn my_method(_self: Py) {} + #[classmethod] + async fn my_classmethod(_cls: Py) {} + #[staticmethod] + async fn my_staticmethod() {} + } + Python::with_gil(|gil| { + let test = r#" + for coro, name, qualname in [ + (my_fn(), "my_fn", "my_fn"), + (my_fn_with_module(), "my_fn", "my_module.my_fn"), + (MyClass().my_method(), "my_method", "MyClass.my_method"), + #(MyClass().my_classmethod(), "my_classmethod", "MyClass.my_classmethod"), + (MyClass.my_staticmethod(), "my_staticmethod", "MyClass.my_staticmethod"), + (MyClassWithModule().my_method(), "my_method", "my_module.MyClassWithModule.my_method"), + #(MyClassWithModule().my_classmethod(), "my_classmethod", "my_module.MyClassWithModule.my_classmethod"), + (MyClassWithModule.my_staticmethod(), "my_staticmethod", "my_module.MyClassWithModule.my_staticmethod"), + ]: + assert coro.__name__ == name and coro.__qualname__ == qualname + "#; + let my_module = PyModule::new(gil, "my_module").unwrap(); + let locals = [ + ("my_fn", wrap_pyfunction!(my_fn, gil).unwrap().deref()), + ( + "my_fn_with_module", + wrap_pyfunction!(my_fn, my_module).unwrap(), + ), + ("MyClass", gil.get_type::()), + ("MyClassWithModule", gil.get_type::()), + ] + .into_py_dict(gil); + py_run!(gil, *locals, &handle_windows(test)); + }) +} + #[test] fn sleep_0_like_coroutine() { #[pyfunction]