diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d50312e7bd8..4aa408eac07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,22 +91,22 @@ jobs: run: cargo build --no-default-features --verbose --target ${{ matrix.platform.rust-target }} - name: Build (all additive features) - run: cargo build --no-default-features --features "macros num-bigint num-complex hashbrown" --verbose --target ${{ matrix.platform.rust-target }} + run: cargo build --no-default-features --features "macros num-bigint num-complex hashbrown serde" --verbose --target ${{ matrix.platform.rust-target }} # Run tests (except on PyPy, because no embedding API). - if: matrix.python-version != 'pypy-3.6' name: Test - run: cargo test --no-default-features --features "macros num-bigint num-complex hashbrown" --target ${{ matrix.platform.rust-target }} + run: cargo test --no-default-features --features "macros num-bigint num-complex hashbrown serde" --target ${{ matrix.platform.rust-target }} # Run tests again, but in abi3 mode - if: matrix.python-version != 'pypy-3.6' name: Test (abi3) - run: cargo test --no-default-features --features "abi3 macros num-bigint num-complex hashbrown" --target ${{ matrix.platform.rust-target }} + run: cargo test --no-default-features --features "abi3 macros num-bigint num-complex hashbrown serde" --target ${{ matrix.platform.rust-target }} # Run tests again, for abi3-py36 (the minimal Python version) - if: (matrix.python-version != 'pypy-3.6') && (matrix.python-version != '3.6') name: Test (abi3-py36) - run: cargo test --no-default-features --features "abi3-py36 macros num-bigint num-complex hashbrown" --target ${{ matrix.platform.rust-target }} + run: cargo test --no-default-features --features "abi3-py36 macros num-bigint num-complex hashbrown serde" --target ${{ matrix.platform.rust-target }} - name: Test proc-macro code run: cargo test --manifest-path=pyo3-macros-backend/Cargo.toml --target ${{ matrix.platform.rust-target }} @@ -143,7 +143,7 @@ jobs: - uses: actions-rs/cargo@v1 with: command: test - args: --features "num-bigint num-complex hashbrown" --no-fail-fast + args: --features "num-bigint num-complex hashbrown serde" --no-fail-fast env: CARGO_INCREMENTAL: 0 RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" diff --git a/CHANGELOG.md b/CHANGELOG.md index bf482f40a3d..a49c7e425b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ PyO3 versions, please see the [migration guide](https://pyo3.rs/master/migration The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Add `serde` feature to support `Serialize/Deserialize` for `Py`. [#1366](https://github.com/PyO3/pyo3/pull/1366) + ## [0.13.1] - 2021-01-10 ### Added - Add support for `#[pyclass(dict)]` and `#[pyclass(weakref)]` with the `abi3` feature on Python 3.9 and up. [#1342](https://github.com/PyO3/pyo3/pull/1342) diff --git a/Cargo.toml b/Cargo.toml index 35c74e7f160..b612b2ca65d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ paste = { version = "1.0.3", optional = true } pyo3-macros = { path = "pyo3-macros", version = "=0.13.1", optional = true } unindent = { version = "0.1.4", optional = true } hashbrown = { version = "0.9", optional = true } +serde = {version = "1.0", optional = true} [dev-dependencies] assert_approx_eq = "1.1.0" @@ -35,6 +36,7 @@ rustversion = "1.0" proptest = { version = "0.10.1", default-features = false, features = ["std"] } # features needed to run the PyO3 test suite pyo3 = { path = ".", default-features = false, features = ["macros", "auto-initialize"] } +serde_json = "1.0.61" [features] default = ["macros", "auto-initialize"] diff --git a/Makefile b/Makefile index a342ffe1f54..ff59f05335b 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,8 @@ fmt: clippy: @touch src/lib.rs # Touching file to ensure that cargo clippy will re-check the project - cargo clippy --features="num-bigint num-complex hashbrown" --tests -- -Dwarnings - cargo clippy --features="abi3 num-bigint num-complex hashbrown" --tests -- -Dwarnings + cargo clippy --features="num-bigint num-complex hashbrown serde" --tests -- -Dwarnings + cargo clippy --features="abi3 num-bigint num-complex hashbrown serde" --tests -- -Dwarnings for example in examples/*; do cargo clippy --manifest-path $$example/Cargo.toml -- -Dwarnings || exit 1; done lint: fmt clippy diff --git a/guide/src/features.md b/guide/src/features.md index 512f2179642..a7b620946b0 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -62,3 +62,24 @@ These macros require a number of dependencies which may not be needed by users w The `nightly` feature needs the nightly Rust compiler. This allows PyO3 to use Rust's unstable specialization feature to apply the following optimizations: - `FromPyObject` for `Vec` and `[T;N]` can perform a `memcpy` when the object supports the Python buffer protocol. - `ToBorrowedObject` can skip a reference count increase when the provided object is a Python native type. + +### `serde` + +The `serde` feature enables (de)serialization of Py objects via [serde](https://serde.rs/). +This allows to use [`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html) on structs that hold references to `#[pyclass]` instances + +```rust + +#[pyclass] +#[derive(Serialize, Deserialize)] +struct Permission { + name: String +} + +#[pyclass] +#[derive(Serialize, Deserialize)] +struct User { + username: String, + permissions: Vec> +} +``` diff --git a/src/lib.rs b/src/lib.rs index 8f50a0f7da3..afa599f2052 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -207,6 +207,9 @@ mod python; pub mod type_object; pub mod types; +#[cfg(feature = "serde")] +pub mod serde; + /// The proc macros, which are also part of the prelude. #[cfg(feature = "macros")] pub mod proc_macro { diff --git a/src/serde.rs b/src/serde.rs new file mode 100644 index 00000000000..e036e37410b --- /dev/null +++ b/src/serde.rs @@ -0,0 +1,36 @@ +use crate::type_object::PyBorrowFlagLayout; +use crate::{Py, PyClass, PyClassInitializer, PyTypeInfo, Python}; +use serde::{de, ser, Deserialize, Deserializer, Serialize, Serializer}; + +impl Serialize for Py +where + T: Serialize + PyClass, +{ + fn serialize(&self, serializer: S) -> Result<::Ok, ::Error> + where + S: Serializer, + { + Python::with_gil(|py| { + self.try_borrow(py) + .map_err(|e| ser::Error::custom(e.to_string()))? + .serialize(serializer) + }) + } +} + +impl<'de, T> Deserialize<'de> for Py +where + T: Into> + PyClass + Deserialize<'de>, + ::BaseLayout: PyBorrowFlagLayout<::BaseType>, +{ + fn deserialize(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let deserialized = T::deserialize(deserializer)?; + + Python::with_gil(|py| { + Py::new(py, deserialized).map_err(|e| de::Error::custom(e.to_string())) + }) + } +} diff --git a/tests/test_serde.rs b/tests/test_serde.rs new file mode 100644 index 00000000000..cb58528ac5f --- /dev/null +++ b/tests/test_serde.rs @@ -0,0 +1,79 @@ +#[cfg(feature = "serde")] +mod test_serde { + use pyo3::prelude::*; + + use serde::{Deserialize, Serialize}; + + #[pyclass] + #[derive(Debug, Serialize, Deserialize)] + struct Group { + name: String, + } + + #[pyclass] + #[derive(Debug, Clone, Serialize, Deserialize)] + struct User { + username: String, + group: Option>, + friends: Vec>, + } + + #[test] + fn test_serialize() { + let friend1 = User { + username: "friend 1".into(), + group: None, + friends: vec![], + }; + let friend2 = User { + username: "friend 2".into(), + ..friend1.clone() + }; + + let user = Python::with_gil(|py| { + let py_friend1 = Py::new(py, friend1).expect("failed to create friend 1"); + let py_friend2 = Py::new(py, friend2).expect("failed to create friend 2"); + + let friends = vec![py_friend1, py_friend2]; + let py_group = Py::new( + py, + Group { + name: "group name".into(), + }, + ) + .unwrap(); + + User { + username: "danya".into(), + group: Some(py_group), + friends, + } + }); + + let serialized = serde_json::to_string(&user).expect("failed to serialize"); + assert_eq!( + serialized, + r#"{"username":"danya","group":{"name":"group name"},"friends":[{"username":"friend 1","group":null,"friends":[]},{"username":"friend 2","group":null,"friends":[]}]}"# + ); + } + + #[test] + fn test_deserialize() { + let serialized = r#"{"username": "danya", "friends": + [{"username": "friend", "group": {"name": "danya's friends"}, "friends": []}]}"#; + let user: User = serde_json::from_str(serialized).expect("failed to deserialize"); + + assert_eq!(user.username, "danya"); + assert_eq!(user.group, None); + assert_eq!(user.friends.len(), 1usize); + let friend = user.friends.get(0).unwrap(); + + Python::with_gil(|py| { + assert_eq!(friend.borrow(py).username, "friend"); + assert_eq!( + friend.borrow(py).group.as_ref().unwrap().borrow(py).name, + "danya's friends" + ) + }); + } +}