-
Notifications
You must be signed in to change notification settings - Fork 760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for wrapping rust closures as python functions #1901
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks 👍 I really want an api such as this. Thank you for doing this 🙏🏻
However there are a couple of issues with this implementation.
- For starters, you can do use-after-frees by transferring references into the closure:
use pyo3::prelude::*;
use pyo3::types::{PyCFunction, PyDict, PyTuple};
fn main() {
let fun = Python::with_gil(|py| {
let stuff = vec![0, 1, 2, 3, 4];
let ref_: &[u8] = &stuff;
let counter_fn = |_args: &PyTuple, _kwargs: Option<&PyDict>| {
println!("called");
println!("This is five: {:?}", ref_.len());
Ok("".into_py(py))
};
let counter_py: Py<PyCFunction> = PyCFunction::new_closure(counter_fn, py).unwrap().into();
counter_py
});
Python::with_gil(|py| {
fun.call0(py).unwrap();
});
}
This is five: 738180135880
Similarly you can probably (I haven't checked) use Py<PyCFunction>
to smuggle non-Send
types to other threads. So you'll want (at least) a Send + 'static
bound.
- The
Box<dyn Fn(&types::PyTuple, Option<&types::PyDict>) -> PyResult<PyObject>>
bound is quite rigid. It would be nice to have closures that don't need args/kwargs arguments or need to return a PyResult. Having to returnOk(py.None())
is going to get annoying. Maybe we can do something clever with traits here?
In the past I've used a pyclass with a call method for this, like so:
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyTuple};
type C = Box<dyn Fn(&PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static>;
#[pyclass]
struct Closure {
wraps: C,
}
#[pymethods]
impl Closure {
#[call]
#[args(args = "*", kwargs = "**")]
fn __call__(
&self,
args: &PyTuple,
kwargs: Option<&PyDict>,
) -> PyResult<()> {
(self.wraps)(args, kwargs)
}
}
fn main() {
Python::with_gil(|py| {
let f = |_: &PyTuple, _: Option<&PyDict>| {
println!("called");
Ok(())
};
let closure = Closure{wraps: Box::new(f)};
closure.__call__(PyTuple::empty(py), None).unwrap();
closure.__call__(PyTuple::empty(py), None).unwrap();
closure.__call__(PyTuple::empty(py), None).unwrap();
});
}
It'll be nice to have a more idiomatic interface for that.
Thanks for the feedback and the detailed example showing why Re making it more idiomatic having some helper functions for the no-args or no-returns case would be an easy thing to do. Alternatively a dedicated macro similar to pub fn new_closure<F, R>(f: F, py: Python) -> PyResult<&PyCFunction>
where
F: Fn(&types::PyTuple, Option<&types::PyDict>) -> PyResult<R> + Send + 'static,
R: IntoPy<PyObject>,
{ |
Thanks, I've wanted this for a long time too! There's a very old issue #804. I don't know if we want to take any inspiration from
I agree that if we wanted to support varied argument types, it would be best to have a proc macro.
I think (This is essentially what |
Thanks all for the quick feedback and the reviews. One thing I forgot to mention, mostly for context, is that this wrapping of a
Ah sorry I haven't noticed this issue. I didn't know about
Makes sense, this seems like a bit of a more involved change so maybe would it be ok to defer it to another PR?
Got it, the PR has been tweaked to use this |
I think it would be better if users can omit the type annotations in the closure arguments - like below. This doesn't compile now: fn main() {
Python::with_gil(|py| {
let counter_fn = |_, _| {
println!("called");
Ok(())
};
let counter_py: Py<PyCFunction> = PyCFunction::new_closure("", counter_fn, py).unwrap().into();
});
}
Perhaps that can actually work. However, this requires higher rank trait bounds (?) and I couldn't get it to work (safely). |
Agreed that it would be better, though maybe if we have a let counter_fn = move |_: &_, _: Option<&_>| {
Ok(())
}; |
@mejrs @birkenfeld @davidhewitt any more thoughts/feedback on this? Would be pretty keen to move forward with it as I have some current use of pyo3 relying on some |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
There some things that need doing:
- An ui test to check that the use-after-free example above doesn't compile (and keeps not compiling). We use trybuild for that, see https://github.com/PyO3/pyo3/blob/main/tests/test_compile_error.rs
- A doc example would be awesome 🙏🏻
- You need to fetch upstream changes to make CI play nice (don't worry about the PyPy failures for now)
src/types/function.rs
Outdated
/// Create a new function from a closure. | ||
pub fn new_closure<F, R>(f: F, py: Python) -> PyResult<&PyCFunction> | ||
where | ||
F: Fn(&types::PyTuple, Option<&types::PyDict>) -> PyResult<R> + Send + 'static, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
F: Fn(&types::PyTuple, Option<&types::PyDict>) -> PyResult<R> + Send + 'static, | |
F: Fn(&types::PyTuple, Option<&types::PyDict>) -> R + Send + 'static, |
(same for the other places where this bound is used)
This allows for returning ()
from the closure, so users don't have to return anything from the closure:
use pyo3::prelude::*;
use pyo3::types::{PyCFunction, PyDict, PyTuple};
fn main() {
let fun = Python::with_gil(|py| {
let fun= |_args: &PyTuple, _kwargs: Option<&PyDict>| {
println!("hi");
};
let counter_py: Py<PyCFunction> = PyCFunction::new_closure(fun, py).unwrap().into();
counter_py
});
Python::with_gil(|py| {
fun.call0(py).unwrap();
});
}
This still allows for returning a result, because of impl<T, E, U> IntoPyCallbackOutput<U> for Result<T, E>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds like a good idea, I've just pushed some changes for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds like a good idea, I've just pushed some changes for this.
1723218
to
57f9ba2
Compare
@mejrs thanks for the suggestions:
|
src/types/function.rs
Outdated
{ | ||
let function_ptr = Box::into_raw(Box::new(f)); | ||
let capsule_ptr = unsafe { | ||
ffi::PyCapsule_New( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could return NULL on error I assume?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch - it could be worth wrapping this ffi call in Py::from_owned_ptr_or_err
, which would also avoid the need for the Py_DECREF
at the end.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch indeed, switched to Py::from_owned_ptr_or_err
and removed the decref call (I also checked manually that the closure drop is still called).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this looks overall great. I have a few final comments / questions!
src/types/function.rs
Outdated
{ | ||
let function_ptr = Box::into_raw(Box::new(f)); | ||
let capsule_ptr = unsafe { | ||
ffi::PyCapsule_New( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch - it could be worth wrapping this ffi call in Py::from_owned_ptr_or_err
, which would also avoid the need for the Py_DECREF
at the end.
method_def: PyMethodDef, | ||
py_or_module: PyFunctionArguments, | ||
py: Python, | ||
mod_ptr: *mut ffi::PyObject, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, so in the closure case the mod_ptr
is set to the capsule, which makes the capsule object appear as the first argument to run_closure
. That's interesting!
src/types/function.rs
Outdated
if let Err(err) = result { | ||
eprintln!("--- PyO3 intercepted a panic when dropping a closure"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a huge fan of this but I don't know what else we could do either. There's always the risk that eprintln!
can also panic and also lead to UB. So it might need a second std::panic::catch_unwind
which aborts if we want to be 100% safe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I've added another std::panic::catch_unwind
layer as you suggest, though this required me to use AssertUnwindSafe
which might be wrong if the type returned by the panic had some interior mutability.
#[test] | ||
fn test_closure_counter() { | ||
let gil = Python::acquire_gil(); | ||
let py = gil.python(); | ||
|
||
let counter = std::cell::RefCell::new(0); | ||
let counter_fn = | ||
move |_args: &types::PyTuple, _kwargs: Option<&types::PyDict>| -> PyResult<i32> { | ||
let mut counter = counter.borrow_mut(); | ||
*counter += 1; | ||
Ok(*counter) | ||
}; | ||
let counter_py = PyCFunction::new_closure(counter_fn, py).unwrap(); | ||
|
||
py_assert!(py, counter_py, "counter_py() == 1"); | ||
py_assert!(py, counter_py, "counter_py() == 2"); | ||
py_assert!(py, counter_py, "counter_py() == 3"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is interesting. I guess because of the GIL we can agree that the RefCell
usage is safe? Do you think it would even be safe for us to support FnMut
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're right and holding the GIL should prevent the boxed function to be called in parallel so FnMut
should be fine (though I'm not super confident on all this). I've switched to allowing FnMut
and also tweaked the example so that it doesn't require a RefCell
anymore and instead just uses a Box
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm, I'm afraid I think I spoke too quickly, sorry. I've tweaked the Box
example a bit to one which holds an &mut
reference to the Box
while calling itself recursively in another thread, which looks to me like it breaks the rule that only one &mut
reference to a value can exist at a time. (i.e. it's probably possible to create UB with FnMut
.)
let mut counter = Box::new(0);
let counter_fn_obj: Arc<RwLock<Option<Py<PyCFunction>>>> = Arc::new(RwLock::new(None));
let counter_fn_obj_clone = counter_fn_obj.clone();
let counter_fn =
move |_args: &types::PyTuple, _kwargs: Option<&types::PyDict>| -> PyResult<i32> {
let counter_value = &mut *counter;
if *counter_value < 5 {
_args.py().allow_threads(|| {
let counter_fn_obj_clone = counter_fn_obj_clone.clone();
let other_thread = std::thread::spawn(move || Python::with_gil(|py| {
counter_fn_obj_clone.read().as_ref().expect("function has been made").call0(py)
}));
*counter_value += 1;
other_thread.join().unwrap()
})?;
}
Ok(*counter_value)
};
let counter_py = PyCFunction::new_closure(counter_fn, py).unwrap();
*counter_fn_obj.write() = Some(counter_py.into());
So I think to be safe let's go back to just Fn
. Sorry about that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I think once we switch back to Fn
this looks good to merge in my opinion!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, thanks for checking this thoroughly, just reverted the FnMut
changes.
I've just rebased and added a CHANGELOG entry, will proceed to merge this. Thanks again! |
Yay, thanks all for the feedback and help getting this out! |
Hello and thanks for all the work on this very useful crate!
My understanding is that there is currently no easy way to wrap Rust closures and call them from Python. This PR adds a new function
PyCFunction::new_closure
to help doing so. Note that I'm not very familiar with the pyo3 internals, so it's unclear to me whether this is the right api for exposing this, any feedback is very welcome, and maybe this should be discussed in a 'need-design' issue first (though I haven't found any related such issue).Technically, this works by boxing the Rust closure twice (to get around the fat pointer size) and the resulting pointer is stored in a Python capsule. Then
PyCFunction_NewEx
is used to create a Python callable function, this uses a 'dispatch' functionrun_closure
that unpacks the capsule and calls the closure. The capsule also registers a destructor to collect the closure when it's not used anymore.There are two small tests, one of them where the closure mutates some Rust based state.
Current limitations include:
RefUnwindSafe
constraint that is used forhandle_panic
. I'm not sure if it's possible to remove it somehow, if this constraint is necessary it should probably be added to thenew_closure
function too.PyMethodDef
using owned string instead of&'static str
could help get around this and allow the user to specify the function name and documentation.Again happy to get feedback on any of these or other limitations I have missed (or feel free to edit the PR too).