diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 24cc00e..efe4f3b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -80,6 +80,12 @@ jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pydantic: [true, false] + abi3: [true, false] + name: Test (pydantic=${{ matrix.pydantic }}, abi3=${{ matrix.abi3 }}) steps: - name: Checkout uses: actions/checkout@v5 @@ -94,16 +100,15 @@ jobs: - name: Cache dependencies uses: Swatinem/rust-cache@v2 - - name: Run tests - uses: actions-rs/cargo@v1 - with: - command: test + - name: Install pydantic + if: matrix.pydantic + run: pip install pydantic - - name: Run tests with abi3 feature + - name: Run tests uses: actions-rs/cargo@v1 with: command: test - args: --features abi3-py38 + args: ${{ matrix.abi3 && '--features pyo3/abi3' || '' }} semver-check: runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 8fd1914..ae46732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "serde-pyobject" -version = "0.7.1" +version = "0.8.0" edition = "2021" description = "PyO3's PyAny as a serde data format" @@ -10,14 +10,12 @@ keywords = ["serde", "pyo3", "python", "ffi"] license = "MIT OR Apache-2.0" [dependencies] -pyo3 = ">=0.26.0" +log = "0.4.28" +pyo3 = ">=0.27.0" serde = "1.0.228" [dev-dependencies] maplit = "1.0.2" -pyo3 = { version = ">=0.26.0", features = ["auto-initialize"] } +pyo3 = { version = "0.27.0", features = ["auto-initialize"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" - -[features] -abi3-py38 = ["pyo3/abi3-py38"] diff --git a/src/dataclass.rs b/src/dataclass.rs new file mode 100644 index 0000000..f72c82c --- /dev/null +++ b/src/dataclass.rs @@ -0,0 +1,20 @@ +use pyo3::{types::*, Bound, PyResult, Python}; + +/// Check if the given object is an instance of a dataclass by `dataclasses.is_dataclass`, +/// and convert it to a PyDict using `dataclasses.asdict`. +pub fn dataclass_as_dict<'py>( + py: Python<'py>, + obj: &Bound<'py, PyAny>, +) -> PyResult>> { + let module = PyModule::import(py, "dataclasses")?; + let is_dataclass_fn = module.getattr("is_dataclass")?; + + if is_dataclass_fn.call1((obj,))?.extract::()? { + let asdict_fn = module.getattr("asdict")?; + let dict_obj = asdict_fn.call1((obj,))?; + let dict = dict_obj.cast_into::()?; + Ok(Some(dict)) + } else { + Ok(None) + } +} diff --git a/src/de.rs b/src/de.rs index 8135cf8..5812e20 100644 --- a/src/de.rs +++ b/src/de.rs @@ -1,4 +1,8 @@ -use crate::error::{Error, Result}; +use crate::{ + dataclass::dataclass_as_dict, + error::{Error, Result}, + pydantic::pydantic_model_as_dict, +}; use pyo3::{types::*, Bound}; use serde::{ de::{self, value::StrDeserializer, MapAccess, SeqAccess, Visitor}, @@ -307,13 +311,13 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> { V: Visitor<'de>, { if self.0.is_instance_of::() { - return visitor.visit_map(MapDeserializer::new(self.0.downcast()?)); + return visitor.visit_map(MapDeserializer::new(self.0.cast()?)); } if self.0.is_instance_of::() { - return visitor.visit_seq(SeqDeserializer::from_list(self.0.downcast()?)); + return visitor.visit_seq(SeqDeserializer::from_list(self.0.cast()?)); } if self.0.is_instance_of::() { - return visitor.visit_seq(SeqDeserializer::from_tuple(self.0.downcast()?)); + return visitor.visit_seq(SeqDeserializer::from_tuple(self.0.cast()?)); } if self.0.is_instance_of::() { return visitor.visit_str(&self.0.extract::()?); @@ -328,10 +332,14 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> { if self.0.is_instance_of::() { return visitor.visit_f64(self.0.extract()?); } + if let Some(dict) = dataclass_as_dict(self.0.py(), &self.0)? { + return visitor.visit_map(MapDeserializer::new(&dict)); + } + if let Some(dict) = pydantic_model_as_dict(self.0.py(), &self.0)? { + return visitor.visit_map(MapDeserializer::new(&dict)); + } if self.0.hasattr("__dict__")? { - return visitor.visit_map(MapDeserializer::new( - self.0.getattr("__dict__")?.downcast()?, - )); + return visitor.visit_map(MapDeserializer::new(self.0.getattr("__dict__")?.cast()?)); } if self.0.hasattr("__slots__")? { // __slots__ and __dict__ are mutually exclusive, see @@ -353,9 +361,9 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> { ) -> Result { // Nested dict `{ "A": { "a": 1, "b": 2 } }` is deserialized as `A { a: 1, b: 2 }` if self.0.is_instance_of::() { - let dict: &Bound = self.0.downcast()?; + let dict: &Bound = self.0.cast()?; if let Some(inner) = dict.get_item(name)? { - if let Ok(inner) = inner.downcast() { + if let Ok(inner) = inner.cast() { return visitor.visit_map(MapDeserializer::new(inner)); } } @@ -418,7 +426,7 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> { }); } if self.0.is_instance_of::() { - let dict: &Bound = self.0.downcast()?; + let dict: &Bound = self.0.cast()?; if dict.len() == 1 { let key = dict.keys().get_item(0).unwrap(); let value = dict.values().get_item(0).unwrap(); @@ -441,10 +449,10 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> { visitor: V, ) -> Result { if self.0.is_instance_of::() { - let dict: &Bound = self.0.downcast()?; + let dict: &Bound = self.0.cast()?; if let Some(value) = dict.get_item(name)? { if value.is_instance_of::() { - let tuple: &Bound = value.downcast()?; + let tuple: &Bound = value.cast()?; return visitor.visit_seq(SeqDeserializer::from_tuple(tuple)); } } diff --git a/src/error.rs b/src/error.rs index 75a861c..86f8c28 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,4 @@ -use pyo3::{exceptions::PyRuntimeError, DowncastError, PyErr}; +use pyo3::{exceptions::PyRuntimeError, CastError, PyErr}; use serde::{de, ser}; use std::fmt::{self, Display}; @@ -12,8 +12,8 @@ impl From for Error { } } -impl From> for Error { - fn from(err: DowncastError) -> Self { +impl From> for Error { + fn from(err: CastError) -> Self { let err: PyErr = err.into(); Error(err) } diff --git a/src/lib.rs b/src/lib.rs index ebcc407..c8b89ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,10 @@ //! to Python objects. //! +mod dataclass; mod de; mod error; +mod pydantic; mod pylit; mod ser; diff --git a/src/pydantic.rs b/src/pydantic.rs new file mode 100644 index 0000000..cab459d --- /dev/null +++ b/src/pydantic.rs @@ -0,0 +1,31 @@ +use pyo3::{types::*, Bound, PyResult, Python}; + +/// Check if the given object is an instance of pydantic BaseModel, +/// and convert it to a PyDict using `model_dump()`. +/// +/// If pydantic is not installed, this function returns Ok(None). +pub fn pydantic_model_as_dict<'py>( + py: Python<'py>, + obj: &Bound<'py, PyAny>, +) -> PyResult>> { + // Try to import pydantic module + let module = match PyModule::import(py, "pydantic") { + Ok(m) => m, + Err(_) => { + // If pydantic import fails for any reason, return None + log::debug!("pydantic module not found; skipping pydantic model check"); + return Ok(None); + } + }; + + let base_model = module.getattr("BaseModel")?.cast_into::()?; + + if obj.is_instance(&base_model)? { + let model_dump_fn = obj.getattr("model_dump")?; + let dict_obj = model_dump_fn.call0()?; + let dict = dict_obj.cast_into::()?; + Ok(Some(dict)) + } else { + Ok(None) + } +} diff --git a/tests/check_revertible.rs b/tests/check_revertible.rs index c79835b..809119b 100644 --- a/tests/check_revertible.rs +++ b/tests/check_revertible.rs @@ -1,5 +1,34 @@ +//! Roundtrip Conversion Tests (Rust → Python → Rust) +//! +//! This test suite verifies that Rust values can be converted to Python and back without +//! data loss. Each test performs the following one-way roundtrip: +//! +//! 1. **Start**: Create a Rust value +//! 2. **Serialize**: Rust value → Python object (via `to_pyobject`) +//! 3. **Deserialize**: Python object → Rust value (via `from_pyobject`) +//! 4. **Assert**: The deserialized Rust value equals the original Rust value +//! +//! **Note**: These tests do NOT verify the reverse direction (Python → Rust → Python). +//! For tests that start with Python objects, see `python_custom_class.rs`, `python_dataclass.rs`, +//! and `python_pydantic.rs`. +//! +//! This ensures that Rust data structures can safely cross the FFI boundary and return +//! to Rust without corruption, which is essential for round-trip serialization scenarios. +//! +//! Coverage includes: +//! - Primitive types (integers, floats, booleans, strings) +//! - Collections (vectors, maps) +//! - Structured data (structs, tuples, newtypes) +//! - Enums (unit, newtype, tuple, and struct variants) +//! - Option types +//! +//! For tests specific to Python types, see: +//! - `python_custom_class.rs` - Custom Python classes with `__dict__` +//! - `python_dataclass.rs` - Python dataclasses (standard library) +//! - `python_pydantic.rs` - Pydantic models (requires `pydantic_support` feature) + use maplit::hashmap; -use pyo3::{ffi::c_str, prelude::*}; +use pyo3::prelude::*; use serde::{Deserialize, Serialize}; use serde_pyobject::{from_pyobject, to_pyobject}; @@ -133,50 +162,3 @@ fn struct_variant() { b: 30, }); } - -#[test] -fn check_python_object() { - #[derive(Debug, PartialEq, Serialize, Deserialize)] - struct MyClass { - name: String, - age: i32, - } - - Python::attach(|py| { - // Create an instance of Python object - py.run( - c_str!( - r#" -class MyClass: - def __init__(self, name: str, age: int): - self.name = name - self.age = age - "# - ), - None, - None, - ) - .unwrap(); - // Create an instance of MyClass - let my_python_class = py - .eval( - c_str!( - r#" -MyClass("John", 30) -"# - ), - None, - None, - ) - .unwrap(); - - let my_rust_class = MyClass { - name: "John".to_string(), - age: 30, - }; - let any: Bound<'_, PyAny> = to_pyobject(py, &my_rust_class).unwrap(); - let rust_version: MyClass = from_pyobject(my_python_class).unwrap(); - let python_version: MyClass = from_pyobject(any).unwrap(); - assert_eq!(rust_version, python_version); - }) -} diff --git a/tests/python_custom_class.rs b/tests/python_custom_class.rs new file mode 100644 index 0000000..0d674da --- /dev/null +++ b/tests/python_custom_class.rs @@ -0,0 +1,68 @@ +//! Custom Python Class Tests (Python → Rust) +//! +//! This test suite verifies deserialization support for custom Python classes +//! that use the `__dict__` attribute to store instance data. +//! +//! These are user-defined Python classes created with the `class` keyword and +//! `__init__` method, which is the most basic and common way to define classes +//! in Python. +//! +//! Each test: +//! 1. Defines a Python class with `__init__` method +//! 2. Creates an instance of that class +//! 3. Deserializes the Python object to a Rust struct via `from_pyobject` +//! 4. Verifies correctness by comparing with a Rust-originated value +//! +//! This ensures that basic Python objects can be seamlessly deserialized into +//! Rust structures, enabling interoperability with user-defined Python types. + +use pyo3::{ffi::c_str, prelude::*}; +use serde::{Deserialize, Serialize}; +use serde_pyobject::{from_pyobject, to_pyobject}; + +#[test] +fn check_python_object() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct MyClass { + name: String, + age: i32, + } + + Python::attach(|py| { + // Create an instance of Python object + py.run( + c_str!( + r#" +class MyClass: + def __init__(self, name: str, age: int): + self.name = name + self.age = age + "# + ), + None, + None, + ) + .unwrap(); + // Create an instance of MyClass + let my_python_class = py + .eval( + c_str!( + r#" +MyClass("John", 30) +"# + ), + None, + None, + ) + .unwrap(); + + let my_rust_class = MyClass { + name: "John".to_string(), + age: 30, + }; + let any: Bound<'_, PyAny> = to_pyobject(py, &my_rust_class).unwrap(); + let rust_version: MyClass = from_pyobject(my_python_class).unwrap(); + let python_version: MyClass = from_pyobject(any).unwrap(); + assert_eq!(rust_version, python_version); + }) +} diff --git a/tests/python_dataclass.rs b/tests/python_dataclass.rs new file mode 100644 index 0000000..cfcb690 --- /dev/null +++ b/tests/python_dataclass.rs @@ -0,0 +1,135 @@ +//! Python Dataclass Tests (Python → Rust) +//! +//! This test suite verifies deserialization support for Python dataclasses, +//! which are part of the Python standard library (Python 3.7+). +//! +//! Dataclasses provide a decorator-based way to automatically generate boilerplate +//! code for classes that primarily store data. They use the `@dataclass` decorator +//! and automatically create `__init__`, `__repr__`, and other methods. +//! +//! The implementation uses `dataclasses.asdict()` to convert dataclass instances +//! into dictionaries before deserialization, ensuring proper handling of all fields +//! including nested dataclasses. +//! +//! Each test: +//! 1. Defines a Python dataclass with `@dataclass` decorator +//! 2. Creates an instance of that dataclass +//! 3. Deserializes the Python object to a Rust struct via `from_pyobject` +//! 4. Verifies correctness by comparing with a Rust-originated value +//! +//! Coverage includes simple dataclasses and nested dataclass structures. + +use pyo3::{ffi::c_str, prelude::*}; +use serde::{Deserialize, Serialize}; +use serde_pyobject::{from_pyobject, to_pyobject}; + +#[test] +fn check_dataclass_object() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct MyClass { + name: String, + age: i32, + } + + Python::attach(|py| { + // Create an instance of Python object + py.run( + c_str!( + r#" +from dataclasses import dataclass +@dataclass +class MyClass: + name: str + age: int +"# + ), + None, + None, + ) + .unwrap(); + // Create an instance of MyClass + let my_python_class = py + .eval( + c_str!( + r#" +MyClass(name="John", age=30) +"# + ), + None, + None, + ) + .unwrap(); + + let my_rust_class = MyClass { + name: "John".to_string(), + age: 30, + }; + let any: Bound<'_, PyAny> = to_pyobject(py, &my_rust_class).unwrap(); + println!("any: {:?}", any); + + let rust_version: MyClass = from_pyobject(my_python_class).unwrap(); + let python_version: MyClass = from_pyobject(any).unwrap(); + assert_eq!(rust_version, python_version); + }) +} + +#[test] +fn check_dataclass_object_nested() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct MyClassNested { + name: String, + age: i32, + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct MyClass { + my_class: MyClassNested, + } + + Python::attach(|py| { + // Create an instance of Python object + py.run( + c_str!( + r#" +from dataclasses import dataclass +@dataclass +class MyClassNested: + name: str + age: int + +@dataclass +class MyClass: + my_class: MyClassNested +"# + ), + None, + None, + ) + .unwrap(); + // Create an instance of MyClass + let my_python_class = py + .eval( + c_str!( + r#" +MyClass(my_class=MyClassNested(name="John", age=30)) +"# + ), + None, + None, + ) + .unwrap(); + + let my_rust_class = MyClass { + my_class: MyClassNested { + name: "John".to_string(), + age: 30, + }, + }; + let any: Bound<'_, PyAny> = to_pyobject(py, &my_rust_class).unwrap(); + println!("any: {:?}", any); + + let rust_version: MyClass = from_pyobject(my_python_class).unwrap(); + let python_version: MyClass = from_pyobject(any).unwrap(); + assert_eq!(rust_version, python_version); + }) +} diff --git a/tests/python_pydantic.rs b/tests/python_pydantic.rs new file mode 100644 index 0000000..ba9a9b3 --- /dev/null +++ b/tests/python_pydantic.rs @@ -0,0 +1,82 @@ +//! Pydantic Model Tests (Python → Rust) +//! +//! This test suite verifies deserialization support for Pydantic models. +//! Pydantic is a popular Python library for data validation and settings management +//! using Python type annotations. +//! +//! **Requirements**: These tests require the `pydantic_support` feature to be enabled. +//! Additionally, Pydantic must be installed in the Python environment. +//! +//! Pydantic models inherit from `pydantic.BaseModel` and provide runtime type checking, +//! data validation, and automatic conversion. The implementation uses the `model_dump()` +//! method to extract data from Pydantic models before deserialization. +//! +//! Each test: +//! 1. Defines a Pydantic model class inheriting from `BaseModel` +//! 2. Creates an instance of that model with validation +//! 3. Deserializes the Python object to a Rust struct via `from_pyobject` +//! 4. Verifies correctness by comparing with a Rust-originated value +//! +//! This enables seamless integration with Python codebases that use Pydantic for +//! data modeling and validation. + +use pyo3::{ffi::c_str, prelude::*}; +use serde::{Deserialize, Serialize}; +use serde_pyobject::{from_pyobject, to_pyobject}; + +#[test] +fn check_pydantic_object() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct MyClass { + name: String, + age: i32, + } + + Python::attach(|py| { + // Try to import pydantic; if it fails, skip the test + let result = py.run( + c_str!( + r#" +from pydantic import BaseModel +class MyClass(BaseModel): + name: str + age: int +"# + ), + None, + None, + ); + + // If pydantic is not installed, skip this test + if let Err(err) = result { + if err.is_instance_of::(py) { + eprintln!("Skipping test: pydantic is not installed"); + return; + } + panic!("Unexpected error: {}", err); + } + // Create an instance of MyClass + let my_python_class = py + .eval( + c_str!( + r#" +MyClass(name="John", age=30) +"# + ), + None, + None, + ) + .unwrap(); + + let my_rust_class = MyClass { + name: "John".to_string(), + age: 30, + }; + let any: Bound<'_, PyAny> = to_pyobject(py, &my_rust_class).unwrap(); + println!("any: {:?}", any); + + let rust_version: MyClass = from_pyobject(my_python_class).unwrap(); + let python_version: MyClass = from_pyobject(any).unwrap(); + assert_eq!(rust_version, python_version); + }) +} diff --git a/tests/to_json_to_pyobject.rs b/tests/to_json_to_pyobject.rs index 7dd4317..5323fc9 100644 --- a/tests/to_json_to_pyobject.rs +++ b/tests/to_json_to_pyobject.rs @@ -1,3 +1,26 @@ +//! Cross-Validation Tests: Direct Serialization vs JSON Roundtrip +//! +//! This test suite validates that `serde_pyobject` produces Python objects equivalent +//! to those created via JSON serialization. Each test compares two paths: +//! +//! 1. **Direct path**: Rust value → Python object (via `to_pyobject`) +//! 2. **JSON path**: Rust value → JSON string → Python object (via `serde_json` + `json.loads`) +//! +//! The assertion ensures both paths produce Python objects that are equal according to +//! Python's equality semantics (`__eq__`). +//! +//! This validates that: +//! - `serde_pyobject` correctly implements the serde data model +//! - The mapping from Rust types to Python objects is consistent with JSON's semantics +//! - No data is lost or transformed incorrectly during direct serialization +//! +//! Coverage includes: +//! - Primitive types (integers, floats, booleans, strings) +//! - Collections (sequences, maps) +//! - Structured data (structs with named and unnamed fields) +//! - Enums (unit, newtype, tuple, and struct variants) +//! - Option types + use maplit::*; use pyo3::prelude::*; use serde::Serialize;