Skip to content

Commit

Permalink
feat: add coroutine __name__/__qualname__ and not-awaited warning
Browse files Browse the repository at this point in the history
  • Loading branch information
wyfo committed Nov 25, 2023
1 parent 9f66846 commit 1bc1640
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 26 deletions.
1 change: 1 addition & 0 deletions newsfragments/3588.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `__name__`/`__qualname__` attributes to `Coroutine`, as well as a Python warning when the coroutine is dropped without having been awaited
28 changes: 21 additions & 7 deletions pyo3-macros-backend/src/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,27 @@ impl<'a> FnSpec<'a> {
let func_name = &self.name;

let rust_call = |args: Vec<TokenStream>| {
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 {
Expand Down
51 changes: 46 additions & 5 deletions src/coroutine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -30,6 +30,8 @@ type FutureOutput = Result<PyResult<PyObject>, Box<dyn Any + Send>>;
/// Python coroutine wrapping a [`Future`].
#[pyclass(crate = "crate")]
pub struct Coroutine {
name: Option<Py<PyString>>,
qualname: Option<Py<PyString>>,
future: Option<Pin<Box<dyn Future<Output = FutureOutput> + Send>>>,
waker: Option<Arc<AsyncioWaker>>,
}
Expand All @@ -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<F, T, E>(future: F) -> Self
pub(crate) fn new<F, T, E>(
name: Option<Py<PyString>>,
mut qualname: Option<Py<PyString>>,
future: F,
) -> Self
where
F: Future<Output = Result<T, E>> + Send + 'static,
T: IntoPy<PyObject>,
PyErr: From<E>,
E: Into<PyErr>,
{
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,
}
Expand Down Expand Up @@ -113,6 +122,20 @@ pub(crate) fn iter_result(result: IterNextOutput<PyObject, PyObject>) -> PyResul

#[pymethods(crate = "crate")]
impl Coroutine {
#[getter]
fn __name__(&self) -> PyResult<Py<PyString>> {
self.name
.clone()
.ok_or_else(|| PyAttributeError::new_err("__name__"))
}

#[getter]
fn __qualname__(&self) -> PyResult<Py<PyString>> {
self.qualname
.clone()
.ok_or_else(|| PyAttributeError::new_err("__qualname__"))
}

fn send(&mut self, py: Python<'_>, _value: &PyAny) -> PyResult<PyObject> {
iter_result(self.poll(py, None)?)
}
Expand All @@ -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("<coroutine>"), |n| n.as_ref(gil).to_str())
.unwrap();
let message = format!("coroutine {} was never awaited", qualname);
PyErr::warn(gil, gil.get_type::<PyRuntimeWarning>(), &message, 2)
.expect("warning error");
self.poll(gil, None).expect("coroutine close error");
})
}
}
}
2 changes: 2 additions & 0 deletions src/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/impl_/coroutine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::future::Future;

use crate::{
coroutine::Coroutine,
types::{PyModule, PyString},
IntoPy, PyClass, PyErr, PyObject, Python,
};

pub fn new_coroutine<F, T, E>(name: &PyString, qualname: &PyString, future: F) -> Coroutine
where
F: Future<Output = Result<T, E>> + Send + 'static,
T: IntoPy<PyObject>,
E: Into<PyErr>,
{
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)
}
14 changes: 0 additions & 14 deletions src/impl_/wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,6 @@ pub fn map_result_into_py<T: IntoPy<PyObject>>(
result.map(|err| err.into_py(py))
}

/// Used to wrap the result of async `#[pyfunction]` and `#[pymethods]`.
#[cfg(feature = "macros")]
pub fn wrap_future<F, R, T>(future: F) -> crate::coroutine::Coroutine
where
F: std::future::Future<Output = R> + Send + 'static,
R: OkWrap<T>,
T: IntoPy<PyObject>,
crate::PyErr: From<R::Error>,
{
crate::coroutine::Coroutine::from_future::<_, T, crate::PyErr>(async move {
OkWrap::wrap(future.await).map_err(Into::into)
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
65 changes: 65 additions & 0 deletions tests/test_coroutine.rs
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down Expand Up @@ -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<Self>) {}
#[classmethod]
async fn my_classmethod(_cls: Py<PyType>) {}
#[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<Self>) {}
#[classmethod]
async fn my_classmethod(_cls: Py<PyType>) {}
#[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::<MyClass>()),
("MyClassWithModule", gil.get_type::<MyClassWithModule>()),
]
.into_py_dict(gil);
py_run!(gil, *locals, &handle_windows(test));
})
}

#[test]
fn sleep_0_like_coroutine() {
#[pyfunction]
Expand Down

0 comments on commit 1bc1640

Please sign in to comment.