davidhewitt / pythonize

MIT License
207 stars 28 forks source link

[noob] How to declare a kind of `pyclass` using `[de]pythonize`? #35

Closed PierreMardon closed 1 year ago

PierreMardon commented 1 year ago

Hi, and sorry in advance because I think I just don't get it 😅

I have a whole and pretty big model that includes numerous complex enums and that is finely tuned using serde, and I ended up here looking for a solution to use this configuration to expose this model in my pyo3 generated module.

Is such a thing possible?

I tried FromPyObject and IntoPyObject implementations that call depythonize() and pythonize().

impl<'source> FromPyObject<'source> for GameState {
    fn extract(ob: &'source PyAny) -> PyResult<Self> {
        match depythonize(ob) {
            Ok(game_state) => Ok(game_state),
            Err(error) => Err(PyErr::new::<GameState, String>(error.to_string()))
        }
    }
}

impl ToPyObject for GameState {
    fn to_object(&self, py: Python<'_>) -> PyObject {
        pythonize(py, &self).expect("Unable to convert GameState to python object")
    }
}

#[cfg(test)]
mod tests {
    use pyo3::Python;
    use pyo3::prelude::*;
    use crate::state::GameState;

    #[test]
    fn test_thing() {
        pyo3::prepare_freethreaded_python();
        let gil = Python::with_gil(|py| {
            assert_eq!(GameState::default().to_object(py).extract(py), GameState::default())
        });
    }
}

Of course I've got a compilation error:

the trait `PyTypeInfo` is not implemented for `GameState`

on the line Err(error) => Err(PyErr::new::<GameState, String>(error.to_string())).

Fair enough! But I'm not sure where to go from there, it doesn't seem appropriate (or maybe I'm not brave enough) to try to fill the PyTypeInfo by hand.

I first thought pythonize was designed to replace pyclass for pyo3 use when serde was in place, but it seems I'm a bit off. I guess the produced and parsed Py* are used otherwise to interface with python? Or maybe I'm missing something?

Thanks for your guidance!

davidhewitt commented 1 year ago

You're close, I think you're just constructing the error wrong. Try this:

Err(PyValueError::new_err(error.to_string()))

PierreMardon commented 1 year ago

Wow, it just worked. Thanks, I went too far based on a misinterpretation of the generics..!

Now for my use case :

#[pymodule]
fn tmp(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_class::<GameState>()?;
    Ok(())
}

is of course not ok because my GameState is no PyClass.

Do you know how could I expose my struct to pyo3 ?

Thanks a lot for your help :)

PierreMardon commented 1 year ago

In case any other noob wonders, you can:

#[pymodule]
fn antique_engine(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_class::<Wrapper>()?;
    m.add_function(wrap_pyfunction!(create, m)?)?;
    m.add_function(wrap_pyfunction!(handle, m)?)?;
    Ok(())
}

#[pyfunction]
fn create() -> PyResult<PyObject> {
    let obj = MyObj::new();
    pyo3::prepare_freethreaded_python();
    let py_obj = Python::with_gil(|py|
        pythonize(py, &obj).unwrap()
    );
    Ok(py_obj)
}

#[pyfunction]
fn handle(obj: &PyAny) {
    let obj: MyObj = depythonize(&obj).unwrap();
}
#[pyclass]
pub struct Wrapper{
    #[pyo3(get, set)]
    pub content: PyObject
}

So as expected you won't get all the benefits from pyclass, but you'll be able to pass anything Serializable / Deserializable from one side to the other.

Very nice work :)