Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1aacb80
Add dataclass and pydantic support
LockedThread Apr 1, 2025
96c7ac5
Remove dataclass and pydantic support from defaults
LockedThread Apr 2, 2025
1cdf01c
remove unnecessary comments
LockedThread Apr 2, 2025
dbe484e
Merge branch 'main' into feat/add-pydantic-dataclasses-support
LockedThread Apr 23, 2025
afdcd06
Merge branch 'main' into feat/add-pydantic-dataclasses-support
LockedThread May 24, 2025
6132759
Run rustfmt
LockedThread May 24, 2025
6e417a2
Merge branch 'main' into pr/LockedThread/23
termoshtt Oct 16, 2025
5c303f4
Fix deprecation warnings and dead code in pydantic/dataclass support
termoshtt Oct 16, 2025
41b4c6b
Merge branch 'main' into pr/LockedThread/23
termoshtt Oct 16, 2025
03cbff3
Address GitHub Copilot review feedback
termoshtt Oct 25, 2025
38f1da8
Remove dataclass feature gating (Python stdlib support)
termoshtt Oct 25, 2025
c9b8f4b
Reorganize test suite with comprehensive documentation
termoshtt Oct 25, 2025
60e70f8
Further split Python type tests into focused files
termoshtt Oct 25, 2025
35e13cc
Drop pydantic_support feature flag and always enable pydantic support
termoshtt Oct 25, 2025
883c467
More
termoshtt Oct 25, 2025
2061fdb
Fix PyO3 version to 0.26.0 on test
termoshtt Oct 25, 2025
6b88be5
Use PyO3 0.27.0 or later
termoshtt Oct 25, 2025
6af04a5
Fix deprecated
termoshtt Oct 25, 2025
73a5eff
Install pydantic in test
termoshtt Oct 25, 2025
4f3f466
Add test matrix for pydantic and abi3 combinations
termoshtt Oct 25, 2025
07436d9
Add name
termoshtt Oct 25, 2025
873eeed
Re-implement dataclass support in a separate module
termoshtt Oct 25, 2025
c05a8ee
Refactor pydantic support to match dataclass pattern
termoshtt Oct 25, 2025
b1806c2
Do not cast to PyFunction for supporting abi3 case
termoshtt Oct 25, 2025
539a4ce
Skip pydantic test when pydantic is not installed
termoshtt Oct 25, 2025
c3c1ee9
Do not use expect
termoshtt Oct 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 4 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"]
20 changes: 20 additions & 0 deletions src/dataclass.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Bound<'py, PyDict>>> {
let module = PyModule::import(py, "dataclasses")?;
let is_dataclass_fn = module.getattr("is_dataclass")?;

if is_dataclass_fn.call1((obj,))?.extract::<bool>()? {
let asdict_fn = module.getattr("asdict")?;
let dict_obj = asdict_fn.call1((obj,))?;
let dict = dict_obj.cast_into::<PyDict>()?;
Ok(Some(dict))
} else {
Ok(None)
}
}
32 changes: 20 additions & 12 deletions src/de.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -307,13 +311,13 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> {
V: Visitor<'de>,
{
if self.0.is_instance_of::<PyDict>() {
return visitor.visit_map(MapDeserializer::new(self.0.downcast()?));
return visitor.visit_map(MapDeserializer::new(self.0.cast()?));
}
if self.0.is_instance_of::<PyList>() {
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::<PyTuple>() {
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::<PyString>() {
return visitor.visit_str(&self.0.extract::<String>()?);
Expand All @@ -328,10 +332,14 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> {
if self.0.is_instance_of::<PyFloat>() {
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
Expand All @@ -353,9 +361,9 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> {
) -> Result<V::Value> {
// Nested dict `{ "A": { "a": 1, "b": 2 } }` is deserialized as `A { a: 1, b: 2 }`
if self.0.is_instance_of::<PyDict>() {
let dict: &Bound<PyDict> = self.0.downcast()?;
let dict: &Bound<PyDict> = 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));
}
}
Expand Down Expand Up @@ -418,7 +426,7 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> {
});
}
if self.0.is_instance_of::<PyDict>() {
let dict: &Bound<PyDict> = self.0.downcast()?;
let dict: &Bound<PyDict> = self.0.cast()?;
if dict.len() == 1 {
let key = dict.keys().get_item(0).unwrap();
let value = dict.values().get_item(0).unwrap();
Expand All @@ -441,10 +449,10 @@ impl<'de> de::Deserializer<'de> for PyAnyDeserializer<'_> {
visitor: V,
) -> Result<V::Value> {
if self.0.is_instance_of::<PyDict>() {
let dict: &Bound<PyDict> = self.0.downcast()?;
let dict: &Bound<PyDict> = self.0.cast()?;
if let Some(value) = dict.get_item(name)? {
if value.is_instance_of::<PyTuple>() {
let tuple: &Bound<PyTuple> = value.downcast()?;
let tuple: &Bound<PyTuple> = value.cast()?;
return visitor.visit_seq(SeqDeserializer::from_tuple(tuple));
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -12,8 +12,8 @@ impl From<PyErr> for Error {
}
}

impl From<DowncastError<'_, '_>> for Error {
fn from(err: DowncastError) -> Self {
impl From<CastError<'_, '_>> for Error {
fn from(err: CastError) -> Self {
let err: PyErr = err.into();
Error(err)
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
//! to Python objects.
//!

mod dataclass;
mod de;
mod error;
mod pydantic;
mod pylit;
mod ser;

Expand Down
31 changes: 31 additions & 0 deletions src/pydantic.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Bound<'py, PyDict>>> {
// 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::<PyType>()?;

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::<PyDict>()?;
Ok(Some(dict))
} else {
Ok(None)
}
}
78 changes: 30 additions & 48 deletions tests/check_revertible.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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);
})
}
68 changes: 68 additions & 0 deletions tests/python_custom_class.rs
Original file line number Diff line number Diff line change
@@ -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);
})
}
Loading
Loading