From b6742920fd6f39ae6fc6cb876c710d7d1d57070c Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Thu, 21 Nov 2024 20:39:38 +0000 Subject: [PATCH 01/28] Update to PyO3 0.23 (#75) * Update to PyO3 0.23 - xyz_bound methods have been renamed to xyz - Use IntoPyObject for conversion to Python * Use c_str! instead of CStr literals * Add CHANGELOG entry * Cargo format --- CHANGELOG.md | 5 + Cargo.toml | 4 +- src/de.rs | 134 +++++++++++++-------------- src/error.rs | 8 ++ src/ser.rs | 94 +++++++++++-------- tests/test_custom_types.rs | 12 ++- tests/test_with_serde_path_to_err.rs | 16 ++-- 7 files changed, 150 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d584aa..07455ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +### Packaging +- Update to PyO3 0.23 + ## 0.22.0 - 2024-08-10 ### Packaging diff --git a/Cargo.toml b/Cargo.toml index 415f5b9..1c25a12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.22.2", default-features = false } +pyo3 = { version = "0.23.1", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.22.2", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.23.1", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" diff --git a/src/de.rs b/src/de.rs index b2dcbdf..04e9740 100644 --- a/src/de.rs +++ b/src/de.rs @@ -14,7 +14,7 @@ where /// Attempt to convert a Python object to an instance of `T` #[deprecated(since = "0.22.0", note = "use `depythonize` instead")] -pub fn depythonize_bound<'py, T>(obj: Bound<'py, PyAny>) -> Result +pub fn depythonize_bound(obj: Bound) -> Result where T: DeserializeOwned, { @@ -46,10 +46,10 @@ impl<'a, 'py> Depythonizer<'a, 'py> { fn set_access(&self) -> Result> { match self.input.downcast::() { - Ok(set) => Ok(PySetAsSequence::from_set(&set)), + Ok(set) => Ok(PySetAsSequence::from_set(set)), Err(e) => { if let Ok(f) = self.input.downcast::() { - Ok(PySetAsSequence::from_frozenset(&f)) + Ok(PySetAsSequence::from_frozenset(f)) } else { Err(e.into()) } @@ -387,13 +387,13 @@ struct PySetAsSequence<'py> { impl<'py> PySetAsSequence<'py> { fn from_set(set: &Bound<'py, PySet>) -> Self { Self { - iter: PyIterator::from_bound_object(&set).expect("set is always iterable"), + iter: PyIterator::from_object(set).expect("set is always iterable"), } } fn from_frozenset(set: &Bound<'py, PyFrozenSet>) -> Self { Self { - iter: PyIterator::from_bound_object(&set).expect("frozenset is always iterable"), + iter: PyIterator::from_object(set).expect("frozenset is always iterable"), } } } @@ -415,8 +415,8 @@ impl<'de> de::SeqAccess<'de> for PySetAsSequence<'_> { } struct PyMappingAccess<'py> { - keys: Bound<'py, PySequence>, - values: Bound<'py, PySequence>, + keys: Bound<'py, PyList>, + values: Bound<'py, PyList>, key_idx: usize, val_idx: usize, len: usize, @@ -524,18 +524,21 @@ impl<'de> de::VariantAccess<'de> for PyEnumAccess<'_, '_> { #[cfg(test)] mod test { + use std::ffi::CStr; + use super::*; use crate::error::ErrorImpl; use maplit::hashmap; - use pyo3::{IntoPy, Python}; + use pyo3::ffi::c_str; + use pyo3::{IntoPyObject, Python}; use serde_json::{json, Value as JsonValue}; - fn test_de(code: &str, expected: &T, expected_json: &JsonValue) + fn test_de(code: &CStr, expected: &T, expected_json: &JsonValue) where T: de::DeserializeOwned + PartialEq + std::fmt::Debug, { Python::with_gil(|py| { - let obj = py.eval_bound(code, None, None).unwrap(); + let obj = py.eval(code, None, None).unwrap(); let actual: T = depythonize(&obj).unwrap(); assert_eq!(&actual, expected); let actual_json: JsonValue = depythonize(&obj).unwrap(); @@ -554,7 +557,7 @@ mod test { let expected = Empty; let expected_json = json!(null); - let code = "None"; + let code = c_str!("None"); test_de(code, &expected, &expected_json); } @@ -580,7 +583,7 @@ mod test { "baz": 45.23, "qux": true }); - let code = "{'foo': 'Foo', 'bar': 8, 'baz': 45.23, 'qux': True}"; + let code = c_str!("{'foo': 'Foo', 'bar': 8, 'baz': 45.23, 'qux': True}"); test_de(code, &expected, &expected_json); } @@ -592,13 +595,11 @@ mod test { bar: usize, } - let code = "{'foo': 'Foo'}"; + let code = c_str!("{'foo': 'Foo'}"); Python::with_gil(|py| { - let locals = PyDict::new_bound(py); - py.run_bound(&format!("obj = {}", code), None, Some(&locals)) - .unwrap(); - let obj = locals.get_item("obj").unwrap().unwrap(); + let locals = PyDict::new(py); + let obj = py.eval(code, None, Some(&locals)).unwrap(); assert!(matches!( *depythonize::(&obj).unwrap_err().inner, ErrorImpl::Message(msg) if msg == "missing field `bar`" @@ -613,7 +614,7 @@ mod test { let expected = TupleStruct("cat".to_string(), -10.05); let expected_json = json!(["cat", -10.05]); - let code = "('cat', -10.05)"; + let code = c_str!("('cat', -10.05)"); test_de(code, &expected, &expected_json); } @@ -622,13 +623,11 @@ mod test { #[derive(Debug, Deserialize, PartialEq)] struct TupleStruct(String, f64); - let code = "('cat', -10.05, 'foo')"; + let code = c_str!("('cat', -10.05, 'foo')"); Python::with_gil(|py| { - let locals = PyDict::new_bound(py); - py.run_bound(&format!("obj = {}", code), None, Some(&locals)) - .unwrap(); - let obj = locals.get_item("obj").unwrap().unwrap(); + let locals = PyDict::new(py); + let obj = py.eval(code, None, Some(&locals)).unwrap(); assert!(matches!( *depythonize::(&obj).unwrap_err().inner, ErrorImpl::IncorrectSequenceLength { expected, got } if expected == 2 && got == 3 @@ -643,7 +642,7 @@ mod test { let expected = TupleStruct("cat".to_string(), -10.05); let expected_json = json!(["cat", -10.05]); - let code = "['cat', -10.05]"; + let code = c_str!("['cat', -10.05]"); test_de(code, &expected, &expected_json); } @@ -651,7 +650,7 @@ mod test { fn test_tuple() { let expected = ("foo".to_string(), 5); let expected_json = json!(["foo", 5]); - let code = "('foo', 5)"; + let code = c_str!("('foo', 5)"); test_de(code, &expected, &expected_json); } @@ -659,7 +658,7 @@ mod test { fn test_tuple_from_pylist() { let expected = ("foo".to_string(), 5); let expected_json = json!(["foo", 5]); - let code = "['foo', 5]"; + let code = c_str!("['foo', 5]"); test_de(code, &expected, &expected_json); } @@ -667,7 +666,7 @@ mod test { fn test_vec_from_pyset() { let expected = vec!["foo".to_string()]; let expected_json = json!(["foo"]); - let code = "{'foo'}"; + let code = c_str!("{'foo'}"); test_de(code, &expected, &expected_json); } @@ -675,7 +674,7 @@ mod test { fn test_vec_from_pyfrozenset() { let expected = vec!["foo".to_string()]; let expected_json = json!(["foo"]); - let code = "frozenset({'foo'})"; + let code = c_str!("frozenset({'foo'})"); test_de(code, &expected, &expected_json); } @@ -683,7 +682,7 @@ mod test { fn test_vec() { let expected = vec![3, 2, 1]; let expected_json = json!([3, 2, 1]); - let code = "[3, 2, 1]"; + let code = c_str!("[3, 2, 1]"); test_de(code, &expected, &expected_json); } @@ -691,7 +690,7 @@ mod test { fn test_vec_from_tuple() { let expected = vec![3, 2, 1]; let expected_json = json!([3, 2, 1]); - let code = "(3, 2, 1)"; + let code = c_str!("(3, 2, 1)"); test_de(code, &expected, &expected_json); } @@ -699,7 +698,7 @@ mod test { fn test_hashmap() { let expected = hashmap! {"foo".to_string() => 4}; let expected_json = json!({"foo": 4 }); - let code = "{'foo': 4}"; + let code = c_str!("{'foo': 4}"); test_de(code, &expected, &expected_json); } @@ -712,7 +711,7 @@ mod test { let expected = Foo::Variant; let expected_json = json!("Variant"); - let code = "'Variant'"; + let code = c_str!("'Variant'"); test_de(code, &expected, &expected_json); } @@ -725,7 +724,7 @@ mod test { let expected = Foo::Tuple(12, "cat".to_string()); let expected_json = json!({"Tuple": [12, "cat"]}); - let code = "{'Tuple': [12, 'cat']}"; + let code = c_str!("{'Tuple': [12, 'cat']}"); test_de(code, &expected, &expected_json); } @@ -738,7 +737,7 @@ mod test { let expected = Foo::NewType("cat".to_string()); let expected_json = json!({"NewType": "cat" }); - let code = "{'NewType': 'cat'}"; + let code = c_str!("{'NewType': 'cat'}"); test_de(code, &expected, &expected_json); } @@ -754,7 +753,7 @@ mod test { bar: 25, }; let expected_json = json!({"Struct": {"foo": "cat", "bar": 25 }}); - let code = "{'Struct': {'foo': 'cat', 'bar': 25}}"; + let code = c_str!("{'Struct': {'foo': 'cat', 'bar': 25}}"); test_de(code, &expected, &expected_json); } #[test] @@ -767,7 +766,7 @@ mod test { let expected = Foo::Tuple(12.0, 'c'); let expected_json = json!([12.0, 'c']); - let code = "[12.0, 'c']"; + let code = c_str!("[12.0, 'c']"); test_de(code, &expected, &expected_json); } @@ -781,7 +780,7 @@ mod test { let expected = Foo::NewType("cat".to_string()); let expected_json = json!("cat"); - let code = "'cat'"; + let code = c_str!("'cat'"); test_de(code, &expected, &expected_json); } @@ -798,7 +797,7 @@ mod test { bar: [2, 5, 3, 1], }; let expected_json = json!({"foo": ["a", "b", "c"], "bar": [2, 5, 3, 1]}); - let code = "{'foo': ['a', 'b', 'c'], 'bar': [2, 5, 3, 1]}"; + let code = c_str!("{'foo': ['a', 'b', 'c'], 'bar': [2, 5, 3, 1]}"); test_de(code, &expected, &expected_json); } @@ -831,7 +830,8 @@ mod test { }; let expected_json = json!({"name": "SomeFoo", "bar": { "value": 13, "variant": { "Tuple": [-1.5, 8]}}}); - let code = "{'name': 'SomeFoo', 'bar': {'value': 13, 'variant': {'Tuple': [-1.5, 8]}}}"; + let code = + c_str!("{'name': 'SomeFoo', 'bar': {'value': 13, 'variant': {'Tuple': [-1.5, 8]}}}"); test_de(code, &expected, &expected_json); } @@ -839,38 +839,38 @@ mod test { fn test_int_limits() { Python::with_gil(|py| { // serde_json::Value supports u64 and i64 as maxiumum sizes - let _: serde_json::Value = depythonize(&u8::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&u8::MIN.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i8::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i8::MIN.into_py(py).into_bound(py)).unwrap(); - - let _: serde_json::Value = depythonize(&u16::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&u16::MIN.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i16::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i16::MIN.into_py(py).into_bound(py)).unwrap(); - - let _: serde_json::Value = depythonize(&u32::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&u32::MIN.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i32::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i32::MIN.into_py(py).into_bound(py)).unwrap(); - - let _: serde_json::Value = depythonize(&u64::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&u64::MIN.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i64::MAX.into_py(py).into_bound(py)).unwrap(); - let _: serde_json::Value = depythonize(&i64::MIN.into_py(py).into_bound(py)).unwrap(); - - let _: u128 = depythonize(&u128::MAX.into_py(py).into_bound(py)).unwrap(); - let _: i128 = depythonize(&u128::MIN.into_py(py).into_bound(py)).unwrap(); - - let _: i128 = depythonize(&i128::MAX.into_py(py).into_bound(py)).unwrap(); - let _: i128 = depythonize(&i128::MIN.into_py(py).into_bound(py)).unwrap(); + let _: serde_json::Value = depythonize(&u8::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&u8::MIN.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i8::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i8::MIN.into_pyobject(py).unwrap()).unwrap(); + + let _: serde_json::Value = depythonize(&u16::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&u16::MIN.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i16::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i16::MIN.into_pyobject(py).unwrap()).unwrap(); + + let _: serde_json::Value = depythonize(&u32::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&u32::MIN.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i32::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i32::MIN.into_pyobject(py).unwrap()).unwrap(); + + let _: serde_json::Value = depythonize(&u64::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&u64::MIN.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i64::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: serde_json::Value = depythonize(&i64::MIN.into_pyobject(py).unwrap()).unwrap(); + + let _: u128 = depythonize(&u128::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: i128 = depythonize(&u128::MIN.into_pyobject(py).unwrap()).unwrap(); + + let _: i128 = depythonize(&i128::MAX.into_pyobject(py).unwrap()).unwrap(); + let _: i128 = depythonize(&i128::MIN.into_pyobject(py).unwrap()).unwrap(); }); } #[test] fn test_deserialize_bytes() { Python::with_gil(|py| { - let obj = PyBytes::new_bound(py, "hello".as_bytes()); + let obj = PyBytes::new(py, "hello".as_bytes()); let actual: Vec = depythonize(&obj).unwrap(); assert_eq!(actual, b"hello"); }) @@ -880,7 +880,7 @@ mod test { fn test_char() { let expected = 'a'; let expected_json = json!("a"); - let code = "'a'"; + let code = c_str!("'a'"); test_de(code, &expected, &expected_json); } @@ -888,7 +888,7 @@ mod test { fn test_unknown_type() { Python::with_gil(|py| { let obj = py - .import_bound("decimal") + .import("decimal") .unwrap() .getattr("Decimal") .unwrap() diff --git a/src/error.rs b/src/error.rs index 4aee7ea..1bcc556 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,7 @@ use pyo3::PyErr; use pyo3::{exceptions::*, DowncastError, DowncastIntoError}; use serde::{de, ser}; +use std::convert::Infallible; use std::error; use std::fmt::{self, Debug, Display}; use std::result; @@ -136,6 +137,13 @@ impl de::Error for PythonizeError { } } +/// Convert an exception raised in Python to a `PythonizeError` +impl From for PythonizeError { + fn from(other: Infallible) -> Self { + match other {} + } +} + /// Convert an exception raised in Python to a `PythonizeError` impl From for PythonizeError { fn from(other: PyErr) -> Self { diff --git a/src/ser.rs b/src/ser.rs index 072212e..efc7e29 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -1,10 +1,10 @@ use std::marker::PhantomData; use pyo3::types::{ - PyAnyMethods, PyDict, PyDictMethods, PyList, PyMapping, PySequence, PyString, PyTuple, + PyDict, PyDictMethods, PyList, PyListMethods, PyMapping, PySequence, PyString, PyTuple, PyTupleMethods, }; -use pyo3::{Bound, IntoPy, PyAny, PyResult, Python, ToPyObject}; +use pyo3::{Bound, BoundObject, IntoPyObject, PyAny, PyResult, Python}; use serde::{ser, Serialize}; use crate::error::{PythonizeError, Result}; @@ -52,12 +52,12 @@ pub trait PythonizeNamedMappingType<'py> { /// Trait for types which can represent a Python sequence pub trait PythonizeListType: Sized { /// Constructor - fn create_sequence( - py: Python, + fn create_sequence<'py, T, U>( + py: Python<'py>, elements: impl IntoIterator, ) -> PyResult> where - T: ToPyObject, + T: IntoPyObject<'py>, U: ExactSizeIterator; } @@ -76,7 +76,7 @@ impl<'py> PythonizeMappingType<'py> for PyDict { type Builder = Bound<'py, Self>; fn builder(py: Python<'py>, _len: Option) -> PyResult { - Ok(Self::new_bound(py)) + Ok(Self::new(py)) } fn push_item( @@ -127,31 +127,28 @@ impl<'py, T: PythonizeMappingType<'py>> PythonizeNamedMappingType<'py> } impl PythonizeListType for PyList { - fn create_sequence( - py: Python, + fn create_sequence<'py, T, U>( + py: Python<'py>, elements: impl IntoIterator, ) -> PyResult> where - T: ToPyObject, + T: IntoPyObject<'py>, U: ExactSizeIterator, { - Ok(PyList::new_bound(py, elements) - .into_any() - .downcast_into() - .unwrap()) + Ok(PyList::new(py, elements)?.into_sequence()) } } impl PythonizeListType for PyTuple { - fn create_sequence( - py: Python, + fn create_sequence<'py, T, U>( + py: Python<'py>, elements: impl IntoIterator, ) -> PyResult> where - T: ToPyObject, + T: IntoPyObject<'py>, U: ExactSizeIterator, { - Ok(PyTuple::new_bound(py, elements).into_sequence()) + Ok(PyTuple::new(py, elements)?.into_sequence()) } } @@ -245,6 +242,20 @@ pub struct PythonMapSerializer<'py, P: PythonizeTypes<'py>> { _types: PhantomData

, } +impl<'py, P: PythonizeTypes<'py>> Pythonizer<'py, P> { + /// The default implementation for serialisation functions. + #[inline] + fn serialise_default(self, v: T) -> Result> + where + T: IntoPyObject<'py>, + >::Error: Into, + { + v.into_pyobject(self.py) + .map(|x| x.into_any().into_bound()) + .map_err(Into::into) + } +} + impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -257,47 +268,47 @@ impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { type SerializeStructVariant = PythonStructVariantSerializer<'py, P>; fn serialize_bool(self, v: bool) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_i8(self, v: i8) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_i16(self, v: i16) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_i32(self, v: i32) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_i64(self, v: i64) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_u8(self, v: u8) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_u16(self, v: u16) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_u32(self, v: u32) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_u64(self, v: u64) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_f32(self, v: f32) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_f64(self, v: f64) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_char(self, v: char) -> Result> { @@ -305,11 +316,11 @@ impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { } fn serialize_str(self, v: &str) -> Result> { - Ok(PyString::new_bound(self.py, v).into_any()) + Ok(PyString::new(self.py, v).into_any()) } fn serialize_bytes(self, v: &[u8]) -> Result> { - Ok(v.into_py(self.py).into_bound(self.py)) + self.serialise_default(v) } fn serialize_none(self) -> Result> { @@ -364,7 +375,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { let mut m = P::NamedMap::builder(self.py, 1, name)?; P::NamedMap::push_field( &mut m, - PyString::new_bound(self.py, variant), + PyString::new(self.py, variant), value.serialize(self)?, )?; Ok(P::NamedMap::finish(m)?.into_any()) @@ -467,7 +478,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeSeq for PythonCollectionSerializ fn end(self) -> Result> { let instance = P::List::create_sequence(self.py, self.items)?; - Ok(instance.to_object(self.py).into_bound(self.py)) + Ok(instance.into_pyobject(self.py)?.into_any()) } } @@ -483,7 +494,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeTuple for PythonCollectionSerial } fn end(self) -> Result> { - Ok(PyTuple::new_bound(self.py, self.items).into_any()) + Ok(PyTuple::new(self.py, self.items)?.into_any()) } } @@ -520,7 +531,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeTupleVariant let mut m = P::NamedMap::builder(self.inner.py, 1, self.name)?; P::NamedMap::push_field( &mut m, - PyString::new_bound(self.inner.py, self.variant), + PyString::new(self.inner.py, self.variant), ser::SerializeTuple::end(self.inner)?, )?; Ok(P::NamedMap::finish(m)?.into_any()) @@ -568,7 +579,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeStruct for PythonStructDictSeria { P::NamedMap::push_field( &mut self.builder, - PyString::new_bound(self.py, key), + PyString::new(self.py, key), pythonize_custom::(self.py, value)?, )?; Ok(()) @@ -591,7 +602,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeStructVariant { P::NamedMap::push_field( &mut self.inner.builder, - PyString::new_bound(self.inner.py, key), + PyString::new(self.inner.py, key), pythonize_custom::(self.inner.py, value)?, )?; Ok(()) @@ -602,7 +613,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeStructVariant let mut m = P::NamedMap::builder(self.inner.py, 1, self.name)?; P::NamedMap::push_field( &mut m, - PyString::new_bound(self.inner.py, self.variant), + PyString::new(self.inner.py, self.variant), v.into_any(), )?; Ok(P::NamedMap::finish(m)?.into_any()) @@ -613,6 +624,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeStructVariant mod test { use super::pythonize; use maplit::hashmap; + use pyo3::ffi::c_str; use pyo3::prelude::*; use pyo3::pybacked::PyBackedStr; use pyo3::types::{PyBytes, PyDict}; @@ -625,11 +637,11 @@ mod test { Python::with_gil(|py| -> PyResult<()> { let obj = pythonize(py, &src)?; - let locals = PyDict::new_bound(py); + let locals = PyDict::new(py); locals.set_item("obj", obj)?; - py.run_bound( - "import json; result = json.dumps(obj, separators=(',', ':'))", + py.run( + c_str!("import json; result = json.dumps(obj, separators=(',', ':'))"), None, Some(&locals), )?; @@ -838,7 +850,7 @@ mod test { Python::with_gil(|py| { assert!(pythonize(py, serde_bytes::Bytes::new(b"foo")) .expect("bytes will always serialize successfully") - .eq(&PyBytes::new_bound(py, b"foo")) + .eq(&PyBytes::new(py, b"foo")) .expect("bytes will always compare successfully")); }); } diff --git a/tests/test_custom_types.rs b/tests/test_custom_types.rs index 5f0084b..1f5dfd4 100644 --- a/tests/test_custom_types.rs +++ b/tests/test_custom_types.rs @@ -4,6 +4,7 @@ use pyo3::{ exceptions::{PyIndexError, PyKeyError}, prelude::*, types::{PyDict, PyMapping, PySequence, PyTuple}, + BoundObject, }; use pythonize::{ depythonize, pythonize_custom, PythonizeListType, PythonizeMappingType, @@ -32,12 +33,12 @@ impl CustomList { } impl PythonizeListType for CustomList { - fn create_sequence( - py: Python, + fn create_sequence<'py, T, U>( + py: Python<'py>, elements: impl IntoIterator, ) -> PyResult> where - T: ToPyObject, + T: IntoPyObject<'py>, U: ExactSizeIterator, { let sequence = Bound::new( @@ -45,8 +46,9 @@ impl PythonizeListType for CustomList { CustomList { items: elements .into_iter() - .map(|item| item.to_object(py)) - .collect(), + .map(|item| item.into_pyobject(py).map(|x| x.into_any().unbind())) + .collect::, T::Error>>() + .map_err(Into::into)?, }, )? .into_any(); diff --git a/tests/test_with_serde_path_to_err.rs b/tests/test_with_serde_path_to_err.rs index 1321c2b..5f2b970 100644 --- a/tests/test_with_serde_path_to_err.rs +++ b/tests/test_with_serde_path_to_err.rs @@ -41,14 +41,14 @@ impl Serialize for CannotSerialize { #[test] fn test_de_valid() { Python::with_gil(|py| { - let pyroot = PyDict::new_bound(py); + let pyroot = PyDict::new(py); pyroot.set_item("root_key", "root_value").unwrap(); - let nested = PyDict::new_bound(py); - let nested_0 = PyDict::new_bound(py); + let nested = PyDict::new(py); + let nested_0 = PyDict::new(py); nested_0.set_item("nested_key", "nested_value_0").unwrap(); nested.set_item("nested_0", nested_0).unwrap(); - let nested_1 = PyDict::new_bound(py); + let nested_1 = PyDict::new(py); nested_1.set_item("nested_key", "nested_value_1").unwrap(); nested.set_item("nested_1", nested_1).unwrap(); @@ -83,14 +83,14 @@ fn test_de_valid() { #[test] fn test_de_invalid() { Python::with_gil(|py| { - let pyroot = PyDict::new_bound(py); + let pyroot = PyDict::new(py); pyroot.set_item("root_key", "root_value").unwrap(); - let nested = PyDict::new_bound(py); - let nested_0 = PyDict::new_bound(py); + let nested = PyDict::new(py); + let nested_0 = PyDict::new(py); nested_0.set_item("nested_key", "nested_value_0").unwrap(); nested.set_item("nested_0", nested_0).unwrap(); - let nested_1 = PyDict::new_bound(py); + let nested_1 = PyDict::new(py); nested_1.set_item("nested_key", 1).unwrap(); nested.set_item("nested_1", nested_1).unwrap(); From bc56c21c3d5dd98759e17ba08bdad059f18c9c64 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 22 Nov 2024 12:37:30 +0000 Subject: [PATCH 02/28] ci: add Python 3.13 and 3.13t testing (#76) * ci: add Python 3.13 and 3.13t testing * skip freethreaded + abi3 combination --- .github/workflows/ci.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b3e2d..d323b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,21 +34,17 @@ jobs: name: python${{ matrix.python-version }} ${{ matrix.os }} rust-${{ matrix.rust}} runs-on: ${{ matrix.os }} strategy: - fail-fast: false # If one platform fails, allow the rest to keep testing. + fail-fast: false # If one platform fails, allow the rest to keep testing. matrix: python-architecture: ["x64"] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: [ - "macos-13", - "ubuntu-latest", - "windows-latest", - ] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] + os: ["macos-13", "ubuntu-latest", "windows-latest"] rust: [stable] include: - - python-version: "3.12" + - python-version: "3.13" os: "ubuntu-latest" rust: "1.63" - - python-version: "3.12" + - python-version: "3.13" python-architecture: "arm64" os: "macos-latest" rust: "stable" @@ -57,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: Quansight-Labs/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.python-python-architecture }} @@ -73,7 +69,9 @@ jobs: - name: Test run: cargo test --verbose - - name: Test (abi3) + # https://github.com/PyO3/pyo3/issues/4709 - can't use abi3 w. freethreaded build + - if: ${{ !endsWith(matrix.python-version, 't') }} + name: Test (abi3) run: cargo test --verbose --features pyo3/abi3-py37 env: From 4491cdb46f35ab3fc1b419beef92171fbef510da Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 22 Nov 2024 16:54:08 +0000 Subject: [PATCH 03/28] release: 0.23 (#77) --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07455ab..c4a1633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.23.0 - 2024-11-22 ### Packaging - Update to PyO3 0.23 diff --git a/Cargo.toml b/Cargo.toml index 1c25a12..f696dea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.22.0" +version = "0.23.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.63" From 637c09f39d82aba7bf8e780cfd59ed874519c819 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Thu, 20 Mar 2025 20:23:42 +0000 Subject: [PATCH 04/28] ci: resolve MSRV from Cargo.toml (#82) * ci: resolve MSRV from Cargo.toml * downgrade dependencies for MSRV compatibility --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d323b30..a09182b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,19 @@ env: CARGO_TERM_COLOR: always jobs: + resolve: + runs-on: ubuntu-latest + outputs: + MSRV: ${{ steps.resolve-msrv.outputs.MSRV }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: resolve MSRV + id: resolve-msrv + run: echo MSRV=`python -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["rust-version"])'` >> $GITHUB_OUTPUT + fmt: runs-on: ubuntu-latest steps: @@ -30,7 +43,7 @@ jobs: - run: cargo clippy --all build: - needs: [fmt] # don't wait for clippy as fails rarely and takes longer + needs: [resolve, fmt] # don't wait for clippy as fails rarely and takes longer name: python${{ matrix.python-version }} ${{ matrix.os }} rust-${{ matrix.rust}} runs-on: ${{ matrix.os }} strategy: @@ -43,7 +56,7 @@ jobs: include: - python-version: "3.13" os: "ubuntu-latest" - rust: "1.63" + rust: ${{ needs.resolve.outputs.MSRV }} - python-version: "3.13" python-architecture: "arm64" os: "macos-latest" @@ -66,6 +79,12 @@ jobs: - uses: Swatinem/rust-cache@v2 continue-on-error: true + - if: ${{ matrix.rust == needs.resolve.outputs.MSRV }} + name: Set dependencies on MSRV + run: cargo +stable update + env: + CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS: fallback + - name: Test run: cargo test --verbose From f2947c9eff9bb532c2ce6361ec96e02b895ca08a Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Thu, 20 Mar 2025 21:29:49 +0100 Subject: [PATCH 05/28] Update PyO3 version to 0.24 (#81) Co-authored-by: David Hewitt --- CHANGELOG.md | 5 +++++ Cargo.toml | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a1633..58ab193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +### Packaging +- Update to PyO3 0.24 + ## 0.23.0 - 2024-11-22 ### Packaging diff --git a/Cargo.toml b/Cargo.toml index f696dea..57de5e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.23.0" +version = "0.24.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.63" @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.23.1", default-features = false } +pyo3 = { version = "0.24", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.23.1", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.24", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" From b51c42eab0045ae0e00b07b4563bb6dca7c2f7b6 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Thu, 20 Mar 2025 20:47:00 +0000 Subject: [PATCH 06/28] remove `depythonize_bound` (#83) * remove `depythonize_bound` * cleanup --- CHANGELOG.md | 3 +++ src/de.rs | 16 ++-------------- src/lib.rs | 2 -- src/ser.rs | 6 +++--- tests/test_custom_types.rs | 2 +- 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ab193..8371995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ### Packaging - Update to PyO3 0.24 +## Removed +- Remove deprecated `depythonize_bound()` + ## 0.23.0 - 2024-11-22 ### Packaging diff --git a/src/de.rs b/src/de.rs index 04e9740..49c0889 100644 --- a/src/de.rs +++ b/src/de.rs @@ -1,5 +1,5 @@ use pyo3::{types::*, Bound}; -use serde::de::{self, DeserializeOwned, IntoDeserializer}; +use serde::de::{self, IntoDeserializer}; use serde::Deserialize; use crate::error::{ErrorImpl, PythonizeError, Result}; @@ -12,15 +12,6 @@ where T::deserialize(&mut Depythonizer::from_object(obj)) } -/// Attempt to convert a Python object to an instance of `T` -#[deprecated(since = "0.22.0", note = "use `depythonize` instead")] -pub fn depythonize_bound(obj: Bound) -> Result -where - T: DeserializeOwned, -{ - T::deserialize(&mut Depythonizer::from_object(&obj)) -} - /// A structure that deserializes Python objects into Rust values pub struct Depythonizer<'a, 'py> { input: &'a Bound<'py, PyAny>, @@ -541,12 +532,9 @@ mod test { let obj = py.eval(code, None, None).unwrap(); let actual: T = depythonize(&obj).unwrap(); assert_eq!(&actual, expected); + let actual_json: JsonValue = depythonize(&obj).unwrap(); assert_eq!(&actual_json, expected_json); - - #[allow(deprecated)] - let actual: T = depythonize_bound(obj.clone()).unwrap(); - assert_eq!(&actual, expected); }); } diff --git a/src/lib.rs b/src/lib.rs index 186fdf6..e625b6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,6 @@ mod de; mod error; mod ser; -#[allow(deprecated)] -pub use crate::de::depythonize_bound; pub use crate::de::{depythonize, Depythonizer}; pub use crate::error::{PythonizeError, Result}; pub use crate::ser::{ diff --git a/src/ser.rs b/src/ser.rs index efc7e29..513a8e2 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -55,7 +55,7 @@ pub trait PythonizeListType: Sized { fn create_sequence<'py, T, U>( py: Python<'py>, elements: impl IntoIterator, - ) -> PyResult> + ) -> PyResult> where T: IntoPyObject<'py>, U: ExactSizeIterator; @@ -130,7 +130,7 @@ impl PythonizeListType for PyList { fn create_sequence<'py, T, U>( py: Python<'py>, elements: impl IntoIterator, - ) -> PyResult> + ) -> PyResult> where T: IntoPyObject<'py>, U: ExactSizeIterator, @@ -143,7 +143,7 @@ impl PythonizeListType for PyTuple { fn create_sequence<'py, T, U>( py: Python<'py>, elements: impl IntoIterator, - ) -> PyResult> + ) -> PyResult> where T: IntoPyObject<'py>, U: ExactSizeIterator, diff --git a/tests/test_custom_types.rs b/tests/test_custom_types.rs index 1f5dfd4..d311c14 100644 --- a/tests/test_custom_types.rs +++ b/tests/test_custom_types.rs @@ -36,7 +36,7 @@ impl PythonizeListType for CustomList { fn create_sequence<'py, T, U>( py: Python<'py>, elements: impl IntoIterator, - ) -> PyResult> + ) -> PyResult> where T: IntoPyObject<'py>, U: ExactSizeIterator, From 5ed610664ec1821051958d713654d59d42fce5b9 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Wed, 26 Mar 2025 15:19:25 -0600 Subject: [PATCH 07/28] replace quansight-labs/setup-python with actions/setup-python (#84) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09182b..6c08531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: Quansight-Labs/setup-python@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.python-python-architecture }} From 3c7d7cef98191f7d9fcc675f72f18b7f63a3ceaa Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Wed, 26 Mar 2025 21:20:17 +0000 Subject: [PATCH 08/28] release: 0.24.0 (#85) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8371995..16b1080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.24.0 - 2025-03-26 ### Packaging - Update to PyO3 0.24 From 49ee947d5f341607f63504a9a9549f67edea81e0 Mon Sep 17 00:00:00 2001 From: Dylan DPC <99973273+Dylan-DPC@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:30:41 +0530 Subject: [PATCH 09/28] Update Cargo.toml (#87) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 57de5e6..8903051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ pyo3 = { version = "0.24", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.24", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.24.1", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" From 6f51e936d926e62b229f0ee850a699acaf66ce65 Mon Sep 17 00:00:00 2001 From: jesse Date: Fri, 23 May 2025 04:34:01 -0700 Subject: [PATCH 10/28] update pyo3 version to 0.25.x (#88) --- Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8903051..b9d4df2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.24.0" +version = "0.25.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.63" @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.24", default-features = false } +pyo3 = { version = "0.25", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.24.1", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.25", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" From 096f83e7b7ec436524c6561d3a99be5159e9bee1 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 23 May 2025 12:35:38 +0100 Subject: [PATCH 11/28] add ci to release crate (#89) --- .github/release.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..5f21b46 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,21 @@ +name: Release Rust Crate + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + environment: release + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} From 435870cd014456dc13a384434f6f43838152c459 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 23 May 2025 12:38:38 +0100 Subject: [PATCH 12/28] fixup workflow location (#90) --- .github/{ => workflows}/release.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/release.yml (100%) diff --git a/.github/release.yml b/.github/workflows/release.yml similarity index 100% rename from .github/release.yml rename to .github/workflows/release.yml From ddfe9f111e8b2f07ec0e5ee5f211074b2880cd73 Mon Sep 17 00:00:00 2001 From: jesse Date: Sat, 14 Jun 2025 03:02:14 -0700 Subject: [PATCH 13/28] fix-typos (#92) --- src/de.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/de.rs b/src/de.rs index 49c0889..2860e48 100644 --- a/src/de.rs +++ b/src/de.rs @@ -121,7 +121,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { self.deserialize_str(visitor) } // Continue with cases which are slower to check because they go - // throuh `isinstance` machinery + // through `isinstance` machinery else if obj.is_instance_of::() || obj.is_instance_of::() { self.deserialize_bytes(visitor) } else if obj.is_instance_of::() { @@ -826,7 +826,7 @@ mod test { #[test] fn test_int_limits() { Python::with_gil(|py| { - // serde_json::Value supports u64 and i64 as maxiumum sizes + // serde_json::Value supports u64 and i64 as maximum sizes let _: serde_json::Value = depythonize(&u8::MAX.into_pyobject(py).unwrap()).unwrap(); let _: serde_json::Value = depythonize(&u8::MIN.into_pyobject(py).unwrap()).unwrap(); let _: serde_json::Value = depythonize(&i8::MAX.into_pyobject(py).unwrap()).unwrap(); From e64436cd21504e3ebd6bb25da0ea36ca42c1e8a3 Mon Sep 17 00:00:00 2001 From: jesse Date: Sat, 30 Aug 2025 01:53:40 -0700 Subject: [PATCH 14/28] did I `GAT` this right? (#91) --- Cargo.toml | 2 +- src/ser.rs | 114 +++++++++++++-------------- tests/test_custom_types.rs | 38 +++++---- tests/test_with_serde_path_to_err.rs | 4 +- 4 files changed, 80 insertions(+), 78 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b9d4df2..d636384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "pythonize" version = "0.25.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" -rust-version = "1.63" +rust-version = "1.65" license = "MIT" description = "Serde Serializer & Deserializer from Rust <--> Python, backed by PyO3." homepage = "https://github.com/davidhewitt/pythonize" diff --git a/src/ser.rs b/src/ser.rs index 513a8e2..dce22e3 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -9,44 +9,46 @@ use serde::{ser, Serialize}; use crate::error::{PythonizeError, Result}; -// TODO: move 'py lifetime into builder once GATs are available in MSRV /// Trait for types which can represent a Python mapping -pub trait PythonizeMappingType<'py> { +pub trait PythonizeMappingType { /// Builder type for Python mappings - type Builder; + type Builder<'py>: 'py; /// Create a builder for a Python mapping - fn builder(py: Python<'py>, len: Option) -> PyResult; + fn builder<'py>(py: Python<'py>, len: Option) -> PyResult>; /// Adds the key-value item to the mapping being built - fn push_item( - builder: &mut Self::Builder, + fn push_item<'py>( + builder: &mut Self::Builder<'py>, key: Bound<'py, PyAny>, value: Bound<'py, PyAny>, ) -> PyResult<()>; /// Build the Python mapping - fn finish(builder: Self::Builder) -> PyResult>; + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult>; } -// TODO: move 'py lifetime into builder once GATs are available in MSRV /// Trait for types which can represent a Python mapping and have a name -pub trait PythonizeNamedMappingType<'py> { +pub trait PythonizeNamedMappingType { /// Builder type for Python mappings with a name - type Builder; + type Builder<'py>: 'py; /// Create a builder for a Python mapping with a name - fn builder(py: Python<'py>, len: usize, name: &'static str) -> PyResult; + fn builder<'py>( + py: Python<'py>, + len: usize, + name: &'static str, + ) -> PyResult>; /// Adds the field to the named mapping being built - fn push_field( - builder: &mut Self::Builder, + fn push_field<'py>( + builder: &mut Self::Builder<'py>, name: Bound<'py, PyString>, value: Bound<'py, PyAny>, ) -> PyResult<()>; /// Build the Python mapping - fn finish(builder: Self::Builder) -> PyResult>; + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult>; } /// Trait for types which can represent a Python sequence @@ -61,33 +63,32 @@ pub trait PythonizeListType: Sized { U: ExactSizeIterator; } -// TODO: remove 'py lifetime once GATs are available in MSRV /// Custom types for serialization -pub trait PythonizeTypes<'py> { +pub trait PythonizeTypes { /// Python map type (should be representable as python mapping) - type Map: PythonizeMappingType<'py>; + type Map: PythonizeMappingType; /// Python (struct-like) named map type (should be representable as python mapping) - type NamedMap: PythonizeNamedMappingType<'py>; + type NamedMap: PythonizeNamedMappingType; /// Python sequence type (should be representable as python sequence) type List: PythonizeListType; } -impl<'py> PythonizeMappingType<'py> for PyDict { - type Builder = Bound<'py, Self>; +impl PythonizeMappingType for PyDict { + type Builder<'py> = Bound<'py, Self>; - fn builder(py: Python<'py>, _len: Option) -> PyResult { + fn builder<'py>(py: Python<'py>, _len: Option) -> PyResult> { Ok(Self::new(py)) } - fn push_item( - builder: &mut Self::Builder, + fn push_item<'py>( + builder: &mut Self::Builder<'py>, key: Bound<'py, PyAny>, value: Bound<'py, PyAny>, ) -> PyResult<()> { builder.set_item(key, value) } - fn finish(builder: Self::Builder) -> PyResult> { + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { Ok(builder.into_mapping()) } } @@ -99,30 +100,31 @@ impl<'py> PythonizeMappingType<'py> for PyDict { /// This adapter is commonly applied to use the same unnamed mapping type for /// both [`PythonizeTypes::Map`] and [`PythonizeTypes::NamedMap`] while only /// implementing [`PythonizeMappingType`]. -pub struct PythonizeUnnamedMappingAdapter<'py, T: PythonizeMappingType<'py>> { +pub struct PythonizeUnnamedMappingAdapter { _unnamed: T, - _marker: PhantomData<&'py ()>, } -impl<'py, T: PythonizeMappingType<'py>> PythonizeNamedMappingType<'py> - for PythonizeUnnamedMappingAdapter<'py, T> -{ - type Builder = >::Builder; +impl PythonizeNamedMappingType for PythonizeUnnamedMappingAdapter { + type Builder<'py> = T::Builder<'py>; - fn builder(py: Python<'py>, len: usize, _name: &'static str) -> PyResult { - ::builder(py, Some(len)) + fn builder<'py>( + py: Python<'py>, + len: usize, + _name: &'static str, + ) -> PyResult> { + T::builder(py, Some(len)) } - fn push_field( - builder: &mut Self::Builder, + fn push_field<'py>( + builder: &mut Self::Builder<'py>, name: Bound<'py, PyString>, value: Bound<'py, PyAny>, ) -> PyResult<()> { - ::push_item(builder, name.into_any(), value) + T::push_item(builder, name.into_any(), value) } - fn finish(builder: Self::Builder) -> PyResult> { - ::finish(builder) + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { + T::finish(builder) } } @@ -154,9 +156,9 @@ impl PythonizeListType for PyTuple { pub struct PythonizeDefault; -impl<'py> PythonizeTypes<'py> for PythonizeDefault { +impl PythonizeTypes for PythonizeDefault { type Map = PyDict; - type NamedMap = PythonizeUnnamedMappingAdapter<'py, PyDict>; + type NamedMap = PythonizeUnnamedMappingAdapter; type List = PyList; } @@ -173,7 +175,7 @@ where pub fn pythonize_custom<'py, P, T>(py: Python<'py>, value: &T) -> Result> where T: ?Sized + Serialize, - P: PythonizeTypes<'py>, + P: PythonizeTypes, { value.serialize(Pythonizer::custom::

(py)) } @@ -221,28 +223,28 @@ pub struct PythonTupleVariantSerializer<'py, P> { } #[doc(hidden)] -pub struct PythonStructVariantSerializer<'py, P: PythonizeTypes<'py>> { +pub struct PythonStructVariantSerializer<'py, P: PythonizeTypes> { name: &'static str, variant: &'static str, inner: PythonStructDictSerializer<'py, P>, } #[doc(hidden)] -pub struct PythonStructDictSerializer<'py, P: PythonizeTypes<'py>> { +pub struct PythonStructDictSerializer<'py, P: PythonizeTypes> { py: Python<'py>, - builder: >::Builder, + builder: ::Builder<'py>, _types: PhantomData

, } #[doc(hidden)] -pub struct PythonMapSerializer<'py, P: PythonizeTypes<'py>> { +pub struct PythonMapSerializer<'py, P: PythonizeTypes> { py: Python<'py>, - builder: >::Builder, + builder: ::Builder<'py>, key: Option>, _types: PhantomData

, } -impl<'py, P: PythonizeTypes<'py>> Pythonizer<'py, P> { +impl<'py, P: PythonizeTypes> Pythonizer<'py, P> { /// The default implementation for serialisation functions. #[inline] fn serialise_default(self, v: T) -> Result> @@ -256,7 +258,7 @@ impl<'py, P: PythonizeTypes<'py>> Pythonizer<'py, P> { } } -impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { +impl<'py, P: PythonizeTypes> ser::Serializer for Pythonizer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; type SerializeSeq = PythonCollectionSerializer<'py, P>; @@ -464,7 +466,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::Serializer for Pythonizer<'py, P> { } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeSeq for PythonCollectionSerializer<'py, P> { +impl<'py, P: PythonizeTypes> ser::SerializeSeq for PythonCollectionSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -482,7 +484,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeSeq for PythonCollectionSerializ } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeTuple for PythonCollectionSerializer<'py, P> { +impl<'py, P: PythonizeTypes> ser::SerializeTuple for PythonCollectionSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -498,7 +500,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeTuple for PythonCollectionSerial } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeTupleStruct for PythonCollectionSerializer<'py, P> { +impl<'py, P: PythonizeTypes> ser::SerializeTupleStruct for PythonCollectionSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -514,9 +516,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeTupleStruct for PythonCollection } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeTupleVariant - for PythonTupleVariantSerializer<'py, P> -{ +impl<'py, P: PythonizeTypes> ser::SerializeTupleVariant for PythonTupleVariantSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -538,7 +538,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeTupleVariant } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeMap for PythonMapSerializer<'py, P> { +impl<'py, P: PythonizeTypes> ser::SerializeMap for PythonMapSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -569,7 +569,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeMap for PythonMapSerializer<'py, } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeStruct for PythonStructDictSerializer<'py, P> { +impl<'py, P: PythonizeTypes> ser::SerializeStruct for PythonStructDictSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; @@ -590,9 +590,7 @@ impl<'py, P: PythonizeTypes<'py>> ser::SerializeStruct for PythonStructDictSeria } } -impl<'py, P: PythonizeTypes<'py>> ser::SerializeStructVariant - for PythonStructVariantSerializer<'py, P> -{ +impl<'py, P: PythonizeTypes> ser::SerializeStructVariant for PythonStructVariantSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; diff --git a/tests/test_custom_types.rs b/tests/test_custom_types.rs index d311c14..32c5768 100644 --- a/tests/test_custom_types.rs +++ b/tests/test_custom_types.rs @@ -58,9 +58,9 @@ impl PythonizeListType for CustomList { } struct PythonizeCustomList; -impl<'py> PythonizeTypes<'py> for PythonizeCustomList { +impl<'py> PythonizeTypes for PythonizeCustomList { type Map = PyDict; - type NamedMap = PythonizeUnnamedMappingAdapter<'py, PyDict>; + type NamedMap = PythonizeUnnamedMappingAdapter; type List = CustomList; } @@ -107,10 +107,10 @@ impl CustomDict { } } -impl<'py> PythonizeMappingType<'py> for CustomDict { - type Builder = Bound<'py, CustomDict>; +impl PythonizeMappingType for CustomDict { + type Builder<'py> = Bound<'py, CustomDict>; - fn builder(py: Python<'py>, len: Option) -> PyResult { + fn builder<'py>(py: Python<'py>, len: Option) -> PyResult> { Bound::new( py, CustomDict { @@ -119,23 +119,23 @@ impl<'py> PythonizeMappingType<'py> for CustomDict { ) } - fn push_item( - builder: &mut Self::Builder, + fn push_item<'py>( + builder: &mut Self::Builder<'py>, key: Bound<'py, PyAny>, value: Bound<'py, PyAny>, ) -> PyResult<()> { unsafe { builder.downcast_unchecked::() }.set_item(key, value) } - fn finish(builder: Self::Builder) -> PyResult> { + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { Ok(unsafe { builder.into_any().downcast_into_unchecked() }) } } struct PythonizeCustomDict; -impl<'py> PythonizeTypes<'py> for PythonizeCustomDict { +impl<'py> PythonizeTypes for PythonizeCustomDict { type Map = CustomDict; - type NamedMap = PythonizeUnnamedMappingAdapter<'py, CustomDict>; + type NamedMap = PythonizeUnnamedMappingAdapter; type List = PyTuple; } @@ -215,10 +215,14 @@ impl NamedCustomDict { } } -impl<'py> PythonizeNamedMappingType<'py> for NamedCustomDict { - type Builder = Bound<'py, NamedCustomDict>; +impl PythonizeNamedMappingType for NamedCustomDict { + type Builder<'py> = Bound<'py, NamedCustomDict>; - fn builder(py: Python<'py>, len: usize, name: &'static str) -> PyResult { + fn builder<'py>( + py: Python<'py>, + len: usize, + name: &'static str, + ) -> PyResult> { Bound::new( py, NamedCustomDict { @@ -228,21 +232,21 @@ impl<'py> PythonizeNamedMappingType<'py> for NamedCustomDict { ) } - fn push_field( - builder: &mut Self::Builder, + fn push_field<'py>( + builder: &mut Self::Builder<'py>, name: Bound<'py, pyo3::types::PyString>, value: Bound<'py, PyAny>, ) -> PyResult<()> { unsafe { builder.downcast_unchecked::() }.set_item(name, value) } - fn finish(builder: Self::Builder) -> PyResult> { + fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { Ok(unsafe { builder.into_any().downcast_into_unchecked() }) } } struct PythonizeNamedCustomDict; -impl<'py> PythonizeTypes<'py> for PythonizeNamedCustomDict { +impl<'py> PythonizeTypes for PythonizeNamedCustomDict { type Map = CustomDict; type NamedMap = NamedCustomDict; type List = PyTuple; diff --git a/tests/test_with_serde_path_to_err.rs b/tests/test_with_serde_path_to_err.rs index 5f2b970..9c3b688 100644 --- a/tests/test_with_serde_path_to_err.rs +++ b/tests/test_with_serde_path_to_err.rs @@ -13,9 +13,9 @@ struct Root { root_map: BTreeMap>, } -impl<'py, T> PythonizeTypes<'py> for Root { +impl<'py, T> PythonizeTypes for Root { type Map = PyDict; - type NamedMap = PythonizeUnnamedMappingAdapter<'py, PyDict>; + type NamedMap = PythonizeUnnamedMappingAdapter; type List = PyList; } From bc3caf522f8d4ddfceda8c6ca438b5e6ff16972d Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Sat, 30 Aug 2025 10:19:23 +0100 Subject: [PATCH 15/28] release: 0.26 (#95) * release: 0.26 * use trusted publishing for release --- .github/workflows/release.yml | 19 +++++++--- CHANGELOG.md | 14 ++++++++ Cargo.toml | 8 ++--- README.md | 2 +- src/de.rs | 40 ++++++++++----------- src/error.rs | 2 +- src/ser.rs | 4 +-- tests/test_custom_types.rs | 52 +++++++++++++--------------- tests/test_with_serde_path_to_err.rs | 10 +++--- 9 files changed, 87 insertions(+), 64 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f21b46..f538d3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,18 +4,29 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + version: + description: The version to build jobs: release: + permissions: + id-token: write + runs-on: ubuntu-latest environment: release steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + # The tag to build or the tag received by the tag event + ref: ${{ github.event.inputs.version || github.ref }} + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: rust-lang/crates-io-auth-action@v1 + id: auth - name: Publish to crates.io run: cargo publish env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b1080..a21bd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.26.0 - 2025-08-30 + +### Packaging +- Bump MSRV to 1.74 +- Update to PyO3 0.26 + +### Changed +- `PythonizeTypes`, `PythonizeMappingType` and `PythonizeNamedMappingType` no longer have a lifetime on the trait, instead the `Builder` type is a GAT. + +## 0.25.0 - 2025-05-23 + +### Packaging +- Update to PyO3 0.25 + ## 0.24.0 - 2025-03-26 ### Packaging diff --git a/Cargo.toml b/Cargo.toml index d636384..c0797e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "pythonize" -version = "0.25.0" +version = "0.26.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" -rust-version = "1.65" +rust-version = "1.74" license = "MIT" description = "Serde Serializer & Deserializer from Rust <--> Python, backed by PyO3." homepage = "https://github.com/davidhewitt/pythonize" @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.25", default-features = false } +pyo3 = { version = "0.26", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.25", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.26", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" diff --git a/README.md b/README.md index 441f29f..2667523 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ let sample = Sample { bar: None }; -Python::with_gil(|py| { +Python::attach(|py| { // Rust -> Python let obj = pythonize(py, &sample).unwrap(); diff --git a/src/de.rs b/src/de.rs index 2860e48..a30dbca 100644 --- a/src/de.rs +++ b/src/de.rs @@ -24,7 +24,7 @@ impl<'a, 'py> Depythonizer<'a, 'py> { } fn sequence_access(&self, expected_len: Option) -> Result> { - let seq = self.input.downcast::()?; + let seq = self.input.cast::()?; let len = self.input.len()?; match expected_len { @@ -36,10 +36,10 @@ impl<'a, 'py> Depythonizer<'a, 'py> { } fn set_access(&self) -> Result> { - match self.input.downcast::() { + match self.input.cast::() { Ok(set) => Ok(PySetAsSequence::from_set(set)), Err(e) => { - if let Ok(f) = self.input.downcast::() { + if let Ok(f) = self.input.cast::() { Ok(PySetAsSequence::from_frozenset(f)) } else { Err(e.into()) @@ -49,7 +49,7 @@ impl<'a, 'py> Depythonizer<'a, 'py> { } fn dict_access(&self) -> Result> { - PyMappingAccess::new(self.input.downcast()?) + PyMappingAccess::new(self.input.cast()?) } fn deserialize_any_int<'de, V>(&self, int: &Bound<'_, PyInt>, visitor: V) -> Result @@ -111,7 +111,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { self.deserialize_unit(visitor) } else if obj.is_instance_of::() { self.deserialize_bool(visitor) - } else if let Ok(x) = obj.downcast::() { + } else if let Ok(x) = obj.cast::() { self.deserialize_any_int(x, visitor) } else if obj.is_instance_of::() || obj.is_instance_of::() { self.deserialize_tuple(obj.len()?, visitor) @@ -128,9 +128,9 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { self.deserialize_f64(visitor) } else if obj.is_instance_of::() || obj.is_instance_of::() { self.deserialize_seq(visitor) - } else if obj.downcast::().is_ok() { + } else if obj.cast::().is_ok() { self.deserialize_tuple(obj.len()?, visitor) - } else if obj.downcast::().is_ok() { + } else if obj.cast::().is_ok() { self.deserialize_map(visitor) } else { Err(obj.get_type().qualname().map_or_else( @@ -151,7 +151,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { where V: de::Visitor<'de>, { - let s = self.input.downcast::()?.to_cow()?; + let s = self.input.cast::()?.to_cow()?; if s.len() != 1 { return Err(PythonizeError::invalid_length_char()); } @@ -175,7 +175,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { where V: de::Visitor<'de>, { - let s = self.input.downcast::()?; + let s = self.input.cast::()?; visitor.visit_str(&s.to_cow()?) } @@ -190,7 +190,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { where V: de::Visitor<'de>, { - let b = self.input.downcast::()?; + let b = self.input.cast::()?; visitor.visit_bytes(b.as_bytes()) } @@ -303,9 +303,9 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { V: de::Visitor<'de>, { let item = &self.input; - if let Ok(s) = item.downcast::() { + if let Ok(s) = item.cast::() { visitor.visit_enum(s.to_cow()?.into_deserializer()) - } else if let Ok(m) = item.downcast::() { + } else if let Ok(m) = item.cast::() { // Get the enum variant from the mapping key if m.len()? != 1 { return Err(PythonizeError::invalid_length_enum()); @@ -313,7 +313,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { let variant: Bound = m .keys()? .get_item(0)? - .downcast_into::() + .cast_into::() .map_err(|_| PythonizeError::dict_key_not_string())?; let value = m.get_item(&variant)?; visitor.visit_enum(PyEnumAccess::new(&value, variant)) @@ -328,7 +328,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { { let s = self .input - .downcast::() + .cast::() .map_err(|_| PythonizeError::dict_key_not_string())?; visitor.visit_str(&s.to_cow()?) } @@ -528,7 +528,7 @@ mod test { where T: de::DeserializeOwned + PartialEq + std::fmt::Debug, { - Python::with_gil(|py| { + Python::attach(|py| { let obj = py.eval(code, None, None).unwrap(); let actual: T = depythonize(&obj).unwrap(); assert_eq!(&actual, expected); @@ -585,7 +585,7 @@ mod test { let code = c_str!("{'foo': 'Foo'}"); - Python::with_gil(|py| { + Python::attach(|py| { let locals = PyDict::new(py); let obj = py.eval(code, None, Some(&locals)).unwrap(); assert!(matches!( @@ -613,7 +613,7 @@ mod test { let code = c_str!("('cat', -10.05, 'foo')"); - Python::with_gil(|py| { + Python::attach(|py| { let locals = PyDict::new(py); let obj = py.eval(code, None, Some(&locals)).unwrap(); assert!(matches!( @@ -825,7 +825,7 @@ mod test { #[test] fn test_int_limits() { - Python::with_gil(|py| { + Python::attach(|py| { // serde_json::Value supports u64 and i64 as maximum sizes let _: serde_json::Value = depythonize(&u8::MAX.into_pyobject(py).unwrap()).unwrap(); let _: serde_json::Value = depythonize(&u8::MIN.into_pyobject(py).unwrap()).unwrap(); @@ -857,7 +857,7 @@ mod test { #[test] fn test_deserialize_bytes() { - Python::with_gil(|py| { + Python::attach(|py| { let obj = PyBytes::new(py, "hello".as_bytes()); let actual: Vec = depythonize(&obj).unwrap(); assert_eq!(actual, b"hello"); @@ -874,7 +874,7 @@ mod test { #[test] fn test_unknown_type() { - Python::with_gil(|py| { + Python::attach(|py| { let obj = py .import("decimal") .unwrap() diff --git a/src/error.rs b/src/error.rs index 1bcc556..7828a71 100644 --- a/src/error.rs +++ b/src/error.rs @@ -73,7 +73,7 @@ pub enum ErrorImpl { Message(String), /// A Python type not supported by the deserializer UnsupportedType(String), - /// A `PyAny` object that failed to downcast to an expected Python type + /// A `PyAny` object that failed to cast to an expected Python type UnexpectedType(String), /// Dict keys should be strings to deserialize to struct fields DictKeyNotString, diff --git a/src/ser.rs b/src/ser.rs index dce22e3..c8e6dd1 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -632,7 +632,7 @@ mod test { where T: Serialize, { - Python::with_gil(|py| -> PyResult<()> { + Python::attach(|py| -> PyResult<()> { let obj = pythonize(py, &src)?; let locals = PyDict::new(py); @@ -845,7 +845,7 @@ mod test { // serde treats &[u8] as a sequence of integers due to lack of specialization test_ser(b"foo", "[102,111,111]"); - Python::with_gil(|py| { + Python::attach(|py| { assert!(pythonize(py, serde_bytes::Bytes::new(b"foo")) .expect("bytes will always serialize successfully") .eq(&PyBytes::new(py, b"foo")) diff --git a/tests/test_custom_types.rs b/tests/test_custom_types.rs index 32c5768..27888d0 100644 --- a/tests/test_custom_types.rs +++ b/tests/test_custom_types.rs @@ -4,7 +4,7 @@ use pyo3::{ exceptions::{PyIndexError, PyKeyError}, prelude::*, types::{PyDict, PyMapping, PySequence, PyTuple}, - BoundObject, + IntoPyObjectExt, }; use pythonize::{ depythonize, pythonize_custom, PythonizeListType, PythonizeMappingType, @@ -15,7 +15,7 @@ use serde_json::{json, Value}; #[pyclass(sequence)] struct CustomList { - items: Vec, + items: Vec>, } #[pymethods] @@ -24,7 +24,7 @@ impl CustomList { self.items.len() } - fn __getitem__(&self, idx: isize) -> PyResult { + fn __getitem__(&self, idx: isize) -> PyResult> { self.items .get(idx as usize) .cloned() @@ -46,14 +46,12 @@ impl PythonizeListType for CustomList { CustomList { items: elements .into_iter() - .map(|item| item.into_pyobject(py).map(|x| x.into_any().unbind())) - .collect::, T::Error>>() - .map_err(Into::into)?, + .map(|item| item.into_py_any(py)) + .collect::>()?, }, - )? - .into_any(); + )?; - Ok(unsafe { sequence.downcast_into_unchecked() }) + Ok(unsafe { sequence.cast_into_unchecked() }) } } @@ -66,7 +64,7 @@ impl<'py> PythonizeTypes for PythonizeCustomList { #[test] fn test_custom_list() { - Python::with_gil(|py| { + Python::attach(|py| { PySequence::register::(py).unwrap(); let serialized = pythonize_custom::(py, &json!([1, 2, 3])).unwrap(); assert!(serialized.is_instance_of::()); @@ -78,7 +76,7 @@ fn test_custom_list() { #[pyclass(mapping)] struct CustomDict { - items: HashMap, + items: HashMap>, } #[pymethods] @@ -87,14 +85,14 @@ impl CustomDict { self.items.len() } - fn __getitem__(&self, key: String) -> PyResult { + fn __getitem__(&self, key: String) -> PyResult> { self.items .get(&key) .cloned() .ok_or_else(|| PyKeyError::new_err(key)) } - fn __setitem__(&mut self, key: String, value: PyObject) { + fn __setitem__(&mut self, key: String, value: Py) { self.items.insert(key, value); } @@ -102,7 +100,7 @@ impl CustomDict { self.items.keys().collect() } - fn values(&self) -> Vec { + fn values(&self) -> Vec> { self.items.values().cloned().collect() } } @@ -124,11 +122,11 @@ impl PythonizeMappingType for CustomDict { key: Bound<'py, PyAny>, value: Bound<'py, PyAny>, ) -> PyResult<()> { - unsafe { builder.downcast_unchecked::() }.set_item(key, value) + unsafe { builder.cast_unchecked::() }.set_item(key, value) } fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { - Ok(unsafe { builder.into_any().downcast_into_unchecked() }) + Ok(unsafe { builder.cast_into_unchecked() }) } } @@ -141,7 +139,7 @@ impl<'py> PythonizeTypes for PythonizeCustomDict { #[test] fn test_custom_dict() { - Python::with_gil(|py| { + Python::attach(|py| { PyMapping::register::(py).unwrap(); let serialized = pythonize_custom::(py, &json!({ "hello": 1, "world": 2 })) @@ -155,7 +153,7 @@ fn test_custom_dict() { #[test] fn test_tuple() { - Python::with_gil(|py| { + Python::attach(|py| { PyMapping::register::(py).unwrap(); let serialized = pythonize_custom::(py, &json!([1, 2, 3, 4])).unwrap(); @@ -169,7 +167,7 @@ fn test_tuple() { #[test] fn test_pythonizer_can_be_created() { // https://github.com/davidhewitt/pythonize/pull/56 - Python::with_gil(|py| { + Python::attach(|py| { let sample = json!({ "hello": 1, "world": 2 }); assert!(sample .serialize(Pythonizer::new(py)) @@ -186,7 +184,7 @@ fn test_pythonizer_can_be_created() { #[pyclass(mapping)] struct NamedCustomDict { name: String, - items: HashMap, + items: HashMap>, } #[pymethods] @@ -195,14 +193,14 @@ impl NamedCustomDict { self.items.len() } - fn __getitem__(&self, key: String) -> PyResult { + fn __getitem__(&self, key: String) -> PyResult> { self.items .get(&key) .cloned() .ok_or_else(|| PyKeyError::new_err(key)) } - fn __setitem__(&mut self, key: String, value: PyObject) { + fn __setitem__(&mut self, key: String, value: Py) { self.items.insert(key, value); } @@ -210,7 +208,7 @@ impl NamedCustomDict { self.items.keys().collect() } - fn values(&self) -> Vec { + fn values(&self) -> Vec> { self.items.values().cloned().collect() } } @@ -237,11 +235,11 @@ impl PythonizeNamedMappingType for NamedCustomDict { name: Bound<'py, pyo3::types::PyString>, value: Bound<'py, PyAny>, ) -> PyResult<()> { - unsafe { builder.downcast_unchecked::() }.set_item(name, value) + unsafe { builder.cast_unchecked::() }.set_item(name, value) } fn finish<'py>(builder: Self::Builder<'py>) -> PyResult> { - Ok(unsafe { builder.into_any().downcast_into_unchecked() }) + Ok(unsafe { builder.cast_into_unchecked() }) } } @@ -260,7 +258,7 @@ struct Struct { #[test] fn test_custom_unnamed_dict() { - Python::with_gil(|py| { + Python::attach(|py| { PyMapping::register::(py).unwrap(); let serialized = pythonize_custom::(py, &Struct { hello: 1, world: 2 }).unwrap(); @@ -273,7 +271,7 @@ fn test_custom_unnamed_dict() { #[test] fn test_custom_named_dict() { - Python::with_gil(|py| { + Python::attach(|py| { PyMapping::register::(py).unwrap(); let serialized = pythonize_custom::(py, &Struct { hello: 1, world: 2 }) diff --git a/tests/test_with_serde_path_to_err.rs b/tests/test_with_serde_path_to_err.rs index 9c3b688..d92c4d1 100644 --- a/tests/test_with_serde_path_to_err.rs +++ b/tests/test_with_serde_path_to_err.rs @@ -40,7 +40,7 @@ impl Serialize for CannotSerialize { #[test] fn test_de_valid() { - Python::with_gil(|py| { + Python::attach(|py| { let pyroot = PyDict::new(py); pyroot.set_item("root_key", "root_value").unwrap(); @@ -82,7 +82,7 @@ fn test_de_valid() { #[test] fn test_de_invalid() { - Python::with_gil(|py| { + Python::attach(|py| { let pyroot = PyDict::new(py); pyroot.set_item("root_key", "root_value").unwrap(); @@ -106,7 +106,7 @@ fn test_de_invalid() { #[test] fn test_ser_valid() { - Python::with_gil(|py| { + Python::attach(|py| { let root = Root { root_key: String::from("root_value"), root_map: BTreeMap::from([ @@ -128,7 +128,7 @@ fn test_ser_valid() { let ser = pythonize::Pythonizer::>::from(py); let pyroot: Bound<'_, PyAny> = serde_path_to_error::serialize(&root, ser).unwrap(); - let pyroot = pyroot.downcast::().unwrap(); + let pyroot = pyroot.cast::().unwrap(); assert_eq!(pyroot.len(), 2); let root_value: String = pyroot @@ -181,7 +181,7 @@ fn test_ser_valid() { #[test] fn test_ser_invalid() { - Python::with_gil(|py| { + Python::attach(|py| { let root = Root { root_key: String::from("root_value"), root_map: BTreeMap::from([ From 9d6d9287693032b869909bfe8dd07a8341749dd9 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 7 Nov 2025 21:50:18 +0000 Subject: [PATCH 16/28] ci: extend test matrix to 3.14, more arm runners (#98) --- .github/workflows/ci.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c08531..3d69440 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,17 +49,21 @@ jobs: strategy: fail-fast: false # If one platform fails, allow the rest to keep testing. matrix: - python-architecture: ["x64"] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] - os: ["macos-13", "ubuntu-latest", "windows-latest"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + os: ["macos-latest", "ubuntu-latest", "windows-latest"] rust: [stable] include: - - python-version: "3.13" + - python-version: "3.14" os: "ubuntu-latest" rust: ${{ needs.resolve.outputs.MSRV }} - - python-version: "3.13" - python-architecture: "arm64" - os: "macos-latest" + - python-version: "3.14" + os: "macos-15-intel" + rust: "stable" + - python-version: "3.14" + os: "ubuntu-24.04-arm" + rust: "stable" + - python-version: "3.14" + os: "windows-11-arm" rust: "stable" steps: @@ -69,7 +73,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.python-python-architecture }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master @@ -88,9 +91,7 @@ jobs: - name: Test run: cargo test --verbose - # https://github.com/PyO3/pyo3/issues/4709 - can't use abi3 w. freethreaded build - - if: ${{ !endsWith(matrix.python-version, 't') }} - name: Test (abi3) + - name: Test (abi3) run: cargo test --verbose --features pyo3/abi3-py37 env: From 89420b2d91721f277f073befa70a95016606e9ed Mon Sep 17 00:00:00 2001 From: Tino Wagner Date: Fri, 7 Nov 2025 22:50:36 +0100 Subject: [PATCH 17/28] Bump PyO3 to 0.27 (#96) * Bump PyO3 to 0.27 Update pyo3 dependency to 0.27. Replace deprecated DowncastError with CastError. --------- Co-authored-by: David Hewitt --- Cargo.toml | 4 ++-- src/error.rs | 14 +++++++------- tests/test_with_serde_path_to_err.rs | 11 +++++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c0797e7..ce406ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.26", default-features = false } +pyo3 = { version = "0.27", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.26", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.27", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" diff --git a/src/error.rs b/src/error.rs index 7828a71..b608106 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,5 @@ use pyo3::PyErr; -use pyo3::{exceptions::*, DowncastError, DowncastIntoError}; +use pyo3::{exceptions::*, CastError, CastIntoError}; use serde::{de, ser}; use std::convert::Infallible; use std::error; @@ -153,18 +153,18 @@ impl From for PythonizeError { } } -/// Handle errors that occur when attempting to use `PyAny::cast_as` -impl<'a, 'py> From> for PythonizeError { - fn from(other: DowncastError<'a, 'py>) -> Self { +/// Handle errors that occur when attempting to use `PyAny::cast` +impl<'a, 'py> From> for PythonizeError { + fn from(other: CastError<'a, 'py>) -> Self { Self { inner: Box::new(ErrorImpl::UnexpectedType(other.to_string())), } } } -/// Handle errors that occur when attempting to use `PyAny::cast_as` -impl<'py> From> for PythonizeError { - fn from(other: DowncastIntoError<'py>) -> Self { +/// Handle errors that occur when attempting to use `PyAny::cast` +impl<'py> From> for PythonizeError { + fn from(other: CastIntoError<'py>) -> Self { Self { inner: Box::new(ErrorImpl::UnexpectedType(other.to_string())), } diff --git a/tests/test_with_serde_path_to_err.rs b/tests/test_with_serde_path_to_err.rs index d92c4d1..82fd8bb 100644 --- a/tests/test_with_serde_path_to_err.rs +++ b/tests/test_with_serde_path_to_err.rs @@ -100,7 +100,10 @@ fn test_de_invalid() { let err = serde_path_to_error::deserialize::<_, Root>(de).unwrap_err(); assert_eq!(err.path().to_string(), "root_map.nested_1.nested_key"); - assert_eq!(err.to_string(), "root_map.nested_1.nested_key: unexpected type: 'int' object cannot be converted to 'PyString'"); + assert_eq!( + err.to_string(), + "root_map.nested_1.nested_key: unexpected type: 'int' object cannot be cast as 'str'" + ); }) } @@ -143,7 +146,7 @@ fn test_ser_valid() { .get_item("root_map") .unwrap() .unwrap() - .downcast_into::() + .cast_into::() .unwrap(); assert_eq!(root_map.len(), 2); @@ -151,7 +154,7 @@ fn test_ser_valid() { .get_item("nested_0") .unwrap() .unwrap() - .downcast_into::() + .cast_into::() .unwrap(); assert_eq!(nested_0.len(), 1); let nested_key_0: String = nested_0 @@ -166,7 +169,7 @@ fn test_ser_valid() { .get_item("nested_1") .unwrap() .unwrap() - .downcast_into::() + .cast_into::() .unwrap(); assert_eq!(nested_1.len(), 1); let nested_key_1: String = nested_1 From 43c714f13d40db35b080659c67b818758cc8c202 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 7 Nov 2025 21:57:31 +0000 Subject: [PATCH 18/28] release: 0.27.0 (#99) --- CHANGELOG.md | 3 +++ Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21bd55..f267740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.27.0 - 2025-11-07 +- Update to PyO3 0.27 + ## 0.26.0 - 2025-08-30 ### Packaging diff --git a/Cargo.toml b/Cargo.toml index ce406ad..4a714a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.26.0" +version = "0.27.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.74" From d22654af72d937222dddb2d6613abee339f154e0 Mon Sep 17 00:00:00 2001 From: Markus Reiter Date: Wed, 18 Feb 2026 10:54:07 +0100 Subject: [PATCH 19/28] Update `pyo3` to 0.28.0. (#104) * Update `pyo3` to 0.28.0. * fixup, bump MSRV to 1.83 --------- Co-authored-by: David Hewitt --- CHANGELOG.md | 5 +++ Cargo.toml | 6 ++-- src/de.rs | 46 +++++++++++++--------------- src/ser.rs | 3 +- tests/test_with_serde_path_to_err.rs | 2 +- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f267740..e7aded5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Bump MSRV to 1.83. +- Update `pyo3` to 0.28. + ## 0.27.0 - 2025-11-07 - Update to PyO3 0.27 diff --git a/Cargo.toml b/Cargo.toml index 4a714a3..641b199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "pythonize" version = "0.27.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" -rust-version = "1.74" +rust-version = "1.83" license = "MIT" description = "Serde Serializer & Deserializer from Rust <--> Python, backed by PyO3." homepage = "https://github.com/davidhewitt/pythonize" @@ -13,11 +13,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } -pyo3 = { version = "0.27", default-features = false } +pyo3 = { version = "0.28", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.27", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.28", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = "1.0" serde_bytes = "0.11" maplit = "1.0.2" diff --git a/src/de.rs b/src/de.rs index a30dbca..8b487db 100644 --- a/src/de.rs +++ b/src/de.rs @@ -520,7 +520,6 @@ mod test { use super::*; use crate::error::ErrorImpl; use maplit::hashmap; - use pyo3::ffi::c_str; use pyo3::{IntoPyObject, Python}; use serde_json::{json, Value as JsonValue}; @@ -545,7 +544,7 @@ mod test { let expected = Empty; let expected_json = json!(null); - let code = c_str!("None"); + let code = c"None"; test_de(code, &expected, &expected_json); } @@ -571,7 +570,7 @@ mod test { "baz": 45.23, "qux": true }); - let code = c_str!("{'foo': 'Foo', 'bar': 8, 'baz': 45.23, 'qux': True}"); + let code = c"{'foo': 'Foo', 'bar': 8, 'baz': 45.23, 'qux': True}"; test_de(code, &expected, &expected_json); } @@ -583,7 +582,7 @@ mod test { bar: usize, } - let code = c_str!("{'foo': 'Foo'}"); + let code = c"{'foo': 'Foo'}"; Python::attach(|py| { let locals = PyDict::new(py); @@ -602,7 +601,7 @@ mod test { let expected = TupleStruct("cat".to_string(), -10.05); let expected_json = json!(["cat", -10.05]); - let code = c_str!("('cat', -10.05)"); + let code = c"('cat', -10.05)"; test_de(code, &expected, &expected_json); } @@ -611,7 +610,7 @@ mod test { #[derive(Debug, Deserialize, PartialEq)] struct TupleStruct(String, f64); - let code = c_str!("('cat', -10.05, 'foo')"); + let code = c"('cat', -10.05, 'foo')"; Python::attach(|py| { let locals = PyDict::new(py); @@ -630,7 +629,7 @@ mod test { let expected = TupleStruct("cat".to_string(), -10.05); let expected_json = json!(["cat", -10.05]); - let code = c_str!("['cat', -10.05]"); + let code = c"['cat', -10.05]"; test_de(code, &expected, &expected_json); } @@ -638,7 +637,7 @@ mod test { fn test_tuple() { let expected = ("foo".to_string(), 5); let expected_json = json!(["foo", 5]); - let code = c_str!("('foo', 5)"); + let code = c"('foo', 5)"; test_de(code, &expected, &expected_json); } @@ -646,7 +645,7 @@ mod test { fn test_tuple_from_pylist() { let expected = ("foo".to_string(), 5); let expected_json = json!(["foo", 5]); - let code = c_str!("['foo', 5]"); + let code = c"['foo', 5]"; test_de(code, &expected, &expected_json); } @@ -654,7 +653,7 @@ mod test { fn test_vec_from_pyset() { let expected = vec!["foo".to_string()]; let expected_json = json!(["foo"]); - let code = c_str!("{'foo'}"); + let code = c"{'foo'}"; test_de(code, &expected, &expected_json); } @@ -662,7 +661,7 @@ mod test { fn test_vec_from_pyfrozenset() { let expected = vec!["foo".to_string()]; let expected_json = json!(["foo"]); - let code = c_str!("frozenset({'foo'})"); + let code = c"frozenset({'foo'})"; test_de(code, &expected, &expected_json); } @@ -670,7 +669,7 @@ mod test { fn test_vec() { let expected = vec![3, 2, 1]; let expected_json = json!([3, 2, 1]); - let code = c_str!("[3, 2, 1]"); + let code = c"[3, 2, 1]"; test_de(code, &expected, &expected_json); } @@ -678,7 +677,7 @@ mod test { fn test_vec_from_tuple() { let expected = vec![3, 2, 1]; let expected_json = json!([3, 2, 1]); - let code = c_str!("(3, 2, 1)"); + let code = c"(3, 2, 1)"; test_de(code, &expected, &expected_json); } @@ -686,7 +685,7 @@ mod test { fn test_hashmap() { let expected = hashmap! {"foo".to_string() => 4}; let expected_json = json!({"foo": 4 }); - let code = c_str!("{'foo': 4}"); + let code = c"{'foo': 4}"; test_de(code, &expected, &expected_json); } @@ -699,7 +698,7 @@ mod test { let expected = Foo::Variant; let expected_json = json!("Variant"); - let code = c_str!("'Variant'"); + let code = c"'Variant'"; test_de(code, &expected, &expected_json); } @@ -712,7 +711,7 @@ mod test { let expected = Foo::Tuple(12, "cat".to_string()); let expected_json = json!({"Tuple": [12, "cat"]}); - let code = c_str!("{'Tuple': [12, 'cat']}"); + let code = c"{'Tuple': [12, 'cat']}"; test_de(code, &expected, &expected_json); } @@ -725,7 +724,7 @@ mod test { let expected = Foo::NewType("cat".to_string()); let expected_json = json!({"NewType": "cat" }); - let code = c_str!("{'NewType': 'cat'}"); + let code = c"{'NewType': 'cat'}"; test_de(code, &expected, &expected_json); } @@ -741,7 +740,7 @@ mod test { bar: 25, }; let expected_json = json!({"Struct": {"foo": "cat", "bar": 25 }}); - let code = c_str!("{'Struct': {'foo': 'cat', 'bar': 25}}"); + let code = c"{'Struct': {'foo': 'cat', 'bar': 25}}"; test_de(code, &expected, &expected_json); } #[test] @@ -754,7 +753,7 @@ mod test { let expected = Foo::Tuple(12.0, 'c'); let expected_json = json!([12.0, 'c']); - let code = c_str!("[12.0, 'c']"); + let code = c"[12.0, 'c']"; test_de(code, &expected, &expected_json); } @@ -768,7 +767,7 @@ mod test { let expected = Foo::NewType("cat".to_string()); let expected_json = json!("cat"); - let code = c_str!("'cat'"); + let code = c"'cat'"; test_de(code, &expected, &expected_json); } @@ -785,7 +784,7 @@ mod test { bar: [2, 5, 3, 1], }; let expected_json = json!({"foo": ["a", "b", "c"], "bar": [2, 5, 3, 1]}); - let code = c_str!("{'foo': ['a', 'b', 'c'], 'bar': [2, 5, 3, 1]}"); + let code = c"{'foo': ['a', 'b', 'c'], 'bar': [2, 5, 3, 1]}"; test_de(code, &expected, &expected_json); } @@ -818,8 +817,7 @@ mod test { }; let expected_json = json!({"name": "SomeFoo", "bar": { "value": 13, "variant": { "Tuple": [-1.5, 8]}}}); - let code = - c_str!("{'name': 'SomeFoo', 'bar': {'value': 13, 'variant': {'Tuple': [-1.5, 8]}}}"); + let code = c"{'name': 'SomeFoo', 'bar': {'value': 13, 'variant': {'Tuple': [-1.5, 8]}}}"; test_de(code, &expected, &expected_json); } @@ -868,7 +866,7 @@ mod test { fn test_char() { let expected = 'a'; let expected_json = json!("a"); - let code = c_str!("'a'"); + let code = c"'a'"; test_de(code, &expected, &expected_json); } diff --git a/src/ser.rs b/src/ser.rs index c8e6dd1..dc62154 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -622,7 +622,6 @@ impl<'py, P: PythonizeTypes> ser::SerializeStructVariant for PythonStructVariant mod test { use super::pythonize; use maplit::hashmap; - use pyo3::ffi::c_str; use pyo3::prelude::*; use pyo3::pybacked::PyBackedStr; use pyo3::types::{PyBytes, PyDict}; @@ -639,7 +638,7 @@ mod test { locals.set_item("obj", obj)?; py.run( - c_str!("import json; result = json.dumps(obj, separators=(',', ':'))"), + c"import json; result = json.dumps(obj, separators=(',', ':'))", None, Some(&locals), )?; diff --git a/tests/test_with_serde_path_to_err.rs b/tests/test_with_serde_path_to_err.rs index 82fd8bb..504dae9 100644 --- a/tests/test_with_serde_path_to_err.rs +++ b/tests/test_with_serde_path_to_err.rs @@ -102,7 +102,7 @@ fn test_de_invalid() { assert_eq!(err.path().to_string(), "root_map.nested_1.nested_key"); assert_eq!( err.to_string(), - "root_map.nested_1.nested_key: unexpected type: 'int' object cannot be cast as 'str'" + "root_map.nested_1.nested_key: unexpected type: 'int' object is not an instance of 'str'" ); }) } From a444b758d6d370bc31b0f2dff4adaff405f4e102 Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:11:31 -0500 Subject: [PATCH 20/28] feat: Support serde_json's arbitrary_precision feature (#102) * feat: Support serde_json's arbitrary_precision feature * docs: Describe arbitrary_precision feature in readme. Update changelog. ci: Run tests for arbitrary_precision feature. * fmt * opt out of default serde-json features --------- Co-authored-by: David Hewitt --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 1 + Cargo.toml | 6 +- README.md | 13 +++- src/de.rs | 47 ++++++++++++- src/ser.rs | 113 +++++++++++++++++++++++++++--- tests/test_arbitrary_precision.rs | 108 ++++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 tests/test_arbitrary_precision.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d69440..a45b1b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: - name: Test (abi3) run: cargo test --verbose --features pyo3/abi3-py37 + - name: Test (arbitrary_precision) + run: cargo test --verbose --features arbitrary_precision + env: RUST_BACKTRACE: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7aded5..744e6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Bump MSRV to 1.83. - Update `pyo3` to 0.28. +- Add `arbitrary_precision` feature ## 0.27.0 - 2025-11-07 - Update to PyO3 0.27 diff --git a/Cargo.toml b/Cargo.toml index 641b199..c4c7c78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,12 +13,16 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } +serde_json = { version = "1.0", optional = true, default-features = false, features = ["std"] } pyo3 = { version = "0.28", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } pyo3 = { version = "0.28", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } -serde_json = "1.0" +serde_json = { version = "1.0", default-features = false, features = ["std"] } serde_bytes = "0.11" maplit = "1.0.2" serde_path_to_error = "0.1.15" + +[features] +arbitrary_precision = ["serde_json", "serde_json/arbitrary_precision"] diff --git a/README.md b/README.md index 2667523..6ade4de 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Pythonize has two main public APIs: `pythonize` and `depythonize`. [Serde]: https://github.com/serde-rs/serde [PyO3]: https://github.com/PyO3/pyo3 -# Examples +## Examples ```rust use serde::{Serialize, Deserialize}; @@ -47,3 +47,14 @@ Python::attach(|py| { assert_eq!(new_sample, sample); }) ``` + +## Features + +### `arbitrary_precision` + +Enable support for `serde_json`'s `arbitrary_precision` feature, which allows handling numbers that exceed the range of `i128`/`u128` when converting `serde_json::Value` to and from Python. + +```toml +[dependencies] +pythonize = { version = "0.28", features = ["arbitrary_precision"] } +``` diff --git a/src/de.rs b/src/de.rs index 8b487db..03107ae 100644 --- a/src/de.rs +++ b/src/de.rs @@ -4,6 +4,9 @@ use serde::Deserialize; use crate::error::{ErrorImpl, PythonizeError, Result}; +#[cfg(feature = "arbitrary_precision")] +const TOKEN: &str = "$serde_json::private::Number"; + /// Attempt to convert a Python object to an instance of `T` pub fn depythonize<'a, 'py, T>(obj: &'a Bound<'py, PyAny>) -> Result where @@ -68,8 +71,7 @@ impl<'a, 'py> Depythonizer<'a, 'py> { } else { visitor.visit_u128(x) } - } else { - let x: i128 = int.extract()?; + } else if let Ok(x) = int.extract::() { if let Ok(x) = i8::try_from(x) { visitor.visit_i8(x) } else if let Ok(x) = i16::try_from(x) { @@ -81,6 +83,19 @@ impl<'a, 'py> Depythonizer<'a, 'py> { } else { visitor.visit_i128(x) } + } else { + #[cfg(feature = "arbitrary_precision")] + { + visitor.visit_map(NumberDeserializer { + number: Some(int.to_string()), + }) + } + #[cfg(not(feature = "arbitrary_precision"))] + { + // Re-attempt to return the original error. + let _: i128 = int.extract()?; + unreachable!() + } } } } @@ -513,6 +528,34 @@ impl<'de> de::VariantAccess<'de> for PyEnumAccess<'_, '_> { } } +// See serde_json +#[cfg(feature = "arbitrary_precision")] +struct NumberDeserializer { + number: Option, +} + +#[cfg(feature = "arbitrary_precision")] +impl<'de> de::MapAccess<'de> for NumberDeserializer { + type Error = PythonizeError; + + fn next_key_seed(&mut self, seed: K) -> Result> + where + K: de::DeserializeSeed<'de>, + { + if self.number.is_none() { + return Ok(None); + } + seed.deserialize(TOKEN.into_deserializer()).map(Some) + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: de::DeserializeSeed<'de>, + { + seed.deserialize(self.number.take().unwrap().into_deserializer()) + } +} + #[cfg(test)] mod test { use std::ffi::CStr; diff --git a/src/ser.rs b/src/ser.rs index dc62154..844f4f0 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -1,5 +1,7 @@ use std::marker::PhantomData; +#[cfg(feature = "arbitrary_precision")] +use pyo3::types::{PyAnyMethods, PyFloat, PyInt}; use pyo3::types::{ PyDict, PyDictMethods, PyList, PyListMethods, PyMapping, PySequence, PyString, PyTuple, PyTupleMethods, @@ -229,6 +231,21 @@ pub struct PythonStructVariantSerializer<'py, P: PythonizeTypes> { inner: PythonStructDictSerializer<'py, P>, } +#[cfg(feature = "arbitrary_precision")] +#[doc(hidden)] +pub enum StructSerializer<'py, P: PythonizeTypes> { + Struct(PythonStructDictSerializer<'py, P>), + Number { + py: Python<'py>, + number_string: Option, + _types: PhantomData

, + }, +} + +#[cfg(not(feature = "arbitrary_precision"))] +#[doc(hidden)] +pub type StructSerializer<'py, P> = PythonStructDictSerializer<'py, P>; + #[doc(hidden)] pub struct PythonStructDictSerializer<'py, P: PythonizeTypes> { py: Python<'py>, @@ -266,7 +283,7 @@ impl<'py, P: PythonizeTypes> ser::Serializer for Pythonizer<'py, P> { type SerializeTupleStruct = PythonCollectionSerializer<'py, P>; type SerializeTupleVariant = PythonTupleVariantSerializer<'py, P>; type SerializeMap = PythonMapSerializer<'py, P>; - type SerializeStruct = PythonStructDictSerializer<'py, P>; + type SerializeStruct = StructSerializer<'py, P>; type SerializeStructVariant = PythonStructVariantSerializer<'py, P>; fn serialize_bool(self, v: bool) -> Result> { @@ -435,16 +452,34 @@ impl<'py, P: PythonizeTypes> ser::Serializer for Pythonizer<'py, P> { }) } - fn serialize_struct( - self, - name: &'static str, - len: usize, - ) -> Result> { - Ok(PythonStructDictSerializer { - py: self.py, - builder: P::NamedMap::builder(self.py, len, name)?, - _types: PhantomData, - }) + fn serialize_struct(self, name: &'static str, len: usize) -> Result> { + #[cfg(feature = "arbitrary_precision")] + { + // With arbitrary_precision enabled, a serde_json::Number serializes as a "$serde_json::private::Number" + // struct with a "$serde_json::private::Number" field, whose value is the String in Number::n. + if name == "$serde_json::private::Number" && len == 1 { + return Ok(StructSerializer::Number { + py: self.py, + number_string: None, + _types: PhantomData, + }); + } + + Ok(StructSerializer::Struct(PythonStructDictSerializer { + py: self.py, + builder: P::NamedMap::builder(self.py, len, name)?, + _types: PhantomData, + })) + } + + #[cfg(not(feature = "arbitrary_precision"))] + { + Ok(PythonStructDictSerializer { + py: self.py, + builder: P::NamedMap::builder(self.py, len, name)?, + _types: PhantomData, + }) + } } fn serialize_struct_variant( @@ -569,6 +604,62 @@ impl<'py, P: PythonizeTypes> ser::SerializeMap for PythonMapSerializer<'py, P> { } } +#[cfg(feature = "arbitrary_precision")] +impl<'py, P: PythonizeTypes> ser::SerializeStruct for StructSerializer<'py, P> { + type Ok = Bound<'py, PyAny>; + type Error = PythonizeError; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + match self { + StructSerializer::Struct(s) => s.serialize_field(key, value), + StructSerializer::Number { number_string, .. } => { + let serde_json::Value::String(s) = value + .serialize(serde_json::value::Serializer) + .map_err(|e| { + PythonizeError::msg(format!("Failed to serialize number: {}", e)) + })? + else { + return Err(PythonizeError::msg("Expected string in serde_json::Number")); + }; + + *number_string = Some(s); + Ok(()) + } + } + } + + fn end(self) -> Result> { + match self { + StructSerializer::Struct(s) => s.end(), + StructSerializer::Number { + py, + number_string: Some(s), + .. + } => { + if let Ok(i) = s.parse::() { + return Ok(PyInt::new(py, i).into_any()); + } + if let Ok(u) = s.parse::() { + return Ok(PyInt::new(py, u).into_any()); + } + if s.chars().any(|c| c == '.' || c == 'e' || c == 'E') { + if let Ok(f) = s.parse::() { + return Ok(PyFloat::new(py, f).into_any()); + } + } + // Fall back to Python's int() constructor, which supports arbitrary precision. + py.get_type::() + .call1((s.as_str(),)) + .map_err(|e| PythonizeError::msg(format!("Invalid number: {}", e))) + } + StructSerializer::Number { .. } => Err(PythonizeError::msg("Empty serde_json::Number")), + } + } +} + impl<'py, P: PythonizeTypes> ser::SerializeStruct for PythonStructDictSerializer<'py, P> { type Ok = Bound<'py, PyAny>; type Error = PythonizeError; diff --git a/tests/test_arbitrary_precision.rs b/tests/test_arbitrary_precision.rs new file mode 100644 index 0000000..dc5b38f --- /dev/null +++ b/tests/test_arbitrary_precision.rs @@ -0,0 +1,108 @@ +#![cfg(feature = "arbitrary_precision")] + +use pyo3::prelude::*; +use pythonize::{depythonize, pythonize}; +use serde_json::Value; + +#[test] +fn test_greater_than_u64_max() { + Python::attach(|py| { + let json_str = r#"18446744073709551616"#; + let value: Value = serde_json::from_str(json_str).unwrap(); + let result = pythonize(py, &value).unwrap(); + let number_str = result.str().unwrap().to_string(); + + assert!(result.is_instance_of::()); + assert_eq!(number_str, "18446744073709551616"); + }); +} + +#[test] +fn test_less_than_i64_min() { + Python::attach(|py| { + let json_str = r#"-9223372036854775809"#; + let value: Value = serde_json::from_str(json_str).unwrap(); + let result = pythonize(py, &value).unwrap(); + let number_str = result.str().unwrap().to_string(); + + assert!(result.is_instance_of::()); + assert_eq!(number_str, "-9223372036854775809"); + }); +} + +#[test] +fn test_float() { + Python::attach(|py| { + let json_str = r#"3.141592653589793238"#; + let value: Value = serde_json::from_str(json_str).unwrap(); + let result = pythonize(py, &value).unwrap(); + let num: f32 = result.extract().unwrap(); + + assert!(result.is_instance_of::()); + assert_eq!(num, 3.141592653589793238); // not {'$serde_json::private::Number': ...} + }); +} + +#[test] +fn test_int() { + Python::attach(|py| { + let json_str = r#"2"#; + let value: Value = serde_json::from_str(json_str).unwrap(); + let result = pythonize(py, &value).unwrap(); + let num: i32 = result.extract().unwrap(); + + assert!(result.is_instance_of::()); + assert_eq!(num, 2); // not {'$serde_json::private::Number': '2'} + }); +} + +#[test] +fn test_serde_error_if_token_empty() { + let json_str = r#"{"$serde_json::private::Number": ""}"#; + let result: Result = serde_json::from_str(json_str); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("EOF while parsing a value")); +} + +#[test] +fn test_serde_error_if_token_invalid() { + let json_str = r#"{"$serde_json::private::Number": 2}"#; + let result: Result = serde_json::from_str(json_str); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid type: integer `2`, expected string containing a number")); +} + +#[test] +fn test_token_valid() { + Python::attach(|py| { + let json_str = r#"{"$serde_json::private::Number": "2"}"#; + let value: Value = serde_json::from_str(json_str).unwrap(); + let result = pythonize(py, &value).unwrap(); + let num: i32 = result.extract().unwrap(); + + assert!(result.is_instance_of::()); + assert_eq!(num, 2); + }); +} + +#[test] +fn test_depythonize_greater_than_u128_max() { + Python::attach(|py| { + // u128::MAX + 1 + let py_int = py + .eval(c"340282366920938463463374607431768211456", None, None) + .unwrap(); + let value: Value = depythonize(&py_int).unwrap(); + + assert!(value.is_number()); + assert_eq!(value.to_string(), "340282366920938463463374607431768211456"); + }); +} From 906e5c3ee40dffc4a6fa16524a614b16b0af1e94 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Wed, 18 Feb 2026 10:22:44 +0000 Subject: [PATCH 21/28] Support deserializing Python dataclass into structs / mappings (#105) --- CHANGELOG.md | 1 + src/de.rs | 219 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 211 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 744e6e8..7c25d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Bump MSRV to 1.83. - Update `pyo3` to 0.28. +- Support deserializing `dataclass` instances to struct-like Rust types. - Add `arbitrary_precision` feature ## 0.27.0 - 2025-11-07 diff --git a/src/de.rs b/src/de.rs index 03107ae..260d625 100644 --- a/src/de.rs +++ b/src/de.rs @@ -1,4 +1,5 @@ -use pyo3::{types::*, Bound}; +use pyo3::exceptions::PyKeyError; +use pyo3::{intern, types::*, Bound}; use serde::de::{self, IntoDeserializer}; use serde::Deserialize; @@ -7,7 +8,16 @@ use crate::error::{ErrorImpl, PythonizeError, Result}; #[cfg(feature = "arbitrary_precision")] const TOKEN: &str = "$serde_json::private::Number"; -/// Attempt to convert a Python object to an instance of `T` +/// Attempt to convert a Python object to an instance of `T`. +/// +/// Generally this only supports Python types that match `serde`'s object model well: +/// - integers (including arbitrary precision integers if the `arbitrary_precision` feature is enabled) +/// - floats +/// - strings +/// - bytes +/// - `collections.abc.Sequence` instances (as serde sequences) +/// - `collections.abc.Mapping` instances (as serde maps) +/// - dataclasses (as serde maps) pub fn depythonize<'a, 'py, T>(obj: &'a Bound<'py, PyAny>) -> Result where T: Deserialize<'a>, @@ -55,6 +65,14 @@ impl<'a, 'py> Depythonizer<'a, 'py> { PyMappingAccess::new(self.input.cast()?) } + fn dataclass_access(&self) -> Result>> { + if let Some(dc) = DataclassCandidate::try_new(self.input) { + Some(PyDataclassAccess::new(dc)).transpose() + } else { + Ok(None) + } + } + fn deserialize_any_int<'de, V>(&self, int: &Bound<'_, PyInt>, visitor: V) -> Result where V: de::Visitor<'de>, @@ -147,6 +165,8 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { self.deserialize_tuple(obj.len()?, visitor) } else if obj.cast::().is_ok() { self.deserialize_map(visitor) + } else if let Some(dc) = DataclassCandidate::try_new(obj) { + visitor.visit_map(PyDataclassAccess::new(dc)?) } else { Err(obj.get_type().qualname().map_or_else( |_| PythonizeError::unsupported_type("unknown"), @@ -293,7 +313,11 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { where V: de::Visitor<'de>, { - visitor.visit_map(self.dict_access()?) + if let Some(dc_access) = self.dataclass_access()? { + visitor.visit_map(dc_access) + } else { + visitor.visit_map(self.dict_access()?) + } } fn deserialize_struct( @@ -470,6 +494,79 @@ impl<'de> de::MapAccess<'de> for PyMappingAccess<'_> { } } +/// Intermediate structure used to denote that `obj` is a dataclass with `fields`. +struct DataclassCandidate<'a, 'py> { + obj: &'a Bound<'py, PyAny>, + fields: Bound<'py, PyAny>, +} + +impl<'a, 'py> DataclassCandidate<'a, 'py> { + fn try_new(obj: &'a Bound<'py, PyAny>) -> Option { + let fields = obj + .getattr_opt(intern!(obj.py(), "__dataclass_fields__")) + .ok() + .flatten()?; + Some(Self { obj, fields }) + } +} + +struct PyDataclassAccess<'py> { + fields: Bound<'py, PyList>, + dict: Bound<'py, PyDict>, + field_idx: usize, + val_idx: usize, + len: usize, +} + +impl<'py> PyDataclassAccess<'py> { + fn new(dc: DataclassCandidate<'_, 'py>) -> Result { + let fields = dc.fields.cast::()?.keys(); + let dict = dc + .obj + .getattr(intern!(dc.obj.py(), "__dict__"))? + .cast_into()?; + let len = fields.len(); + Ok(Self { + fields, + dict, + field_idx: 0, + val_idx: 0, + len, + }) + } +} + +impl<'de> de::MapAccess<'de> for PyDataclassAccess<'_> { + type Error = PythonizeError; + + fn next_key_seed(&mut self, seed: K) -> Result> + where + K: de::DeserializeSeed<'de>, + { + if self.field_idx < self.len { + let item = self.fields.get_item(self.field_idx)?; + self.field_idx += 1; + seed.deserialize(&mut Depythonizer::from_object(&item)) + .map(Some) + } else { + Ok(None) + } + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: de::DeserializeSeed<'de>, + { + let key = self.fields.get_item(self.val_idx)?; + let value = self + .dict + .get_item(&key)? + .ok_or_else(|| PyKeyError::new_err(key.unbind()))?; + self.val_idx += 1; + seed.deserialize(&mut Depythonizer::from_object(&value)) + } +} + struct PyEnumAccess<'a, 'py> { de: Depythonizer<'a, 'py>, variant: Bound<'py, PyString>, @@ -558,7 +655,7 @@ impl<'de> de::MapAccess<'de> for NumberDeserializer { #[cfg(test)] mod test { - use std::ffi::CStr; + use std::{collections::HashMap, ffi::CStr}; use super::*; use crate::error::ErrorImpl; @@ -572,14 +669,21 @@ mod test { { Python::attach(|py| { let obj = py.eval(code, None, None).unwrap(); - let actual: T = depythonize(&obj).unwrap(); - assert_eq!(&actual, expected); - - let actual_json: JsonValue = depythonize(&obj).unwrap(); - assert_eq!(&actual_json, expected_json); + test_de_with_obj(&obj, expected, expected_json); }); } + fn test_de_with_obj(obj: &Bound<'_, PyAny>, expected: &T, expected_json: &JsonValue) + where + T: de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let actual: T = depythonize(obj).unwrap(); + assert_eq!(&actual, expected); + + let actual_json: JsonValue = depythonize(obj).unwrap(); + assert_eq!(&actual_json, expected_json); + } + #[test] fn test_empty_struct() { #[derive(Debug, Deserialize, PartialEq)] @@ -930,4 +1034,101 @@ mod test { )); }); } + + #[test] + fn test_dataclass() { + let code = c"\ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + y: int + +point = Point(1, 2)"; + + #[derive(Debug, Deserialize, PartialEq)] + struct Point { + x: i32, + y: i32, + } + + let expected = Point { x: 1, y: 2 }; + let expected_json = json!({"x": 1, "y": 2}); + + Python::attach(|py| { + let locals = PyDict::new(py); + py.run(code, None, Some(&locals)).unwrap(); + let obj = locals.get_item("point").unwrap().unwrap(); + test_de_with_obj(&obj, &expected, &expected_json); + + let map: HashMap = depythonize(&obj).unwrap(); + assert_eq!(map.len(), 2); + assert_eq!(*map.get("x").unwrap(), 1); + assert_eq!(*map.get("y").unwrap(), 2); + }); + } + + #[test] + fn test_dataclass_missing_field() { + let code = c"\ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + y: int + +point = Point(1, 2)"; + + #[derive(Debug, Deserialize, PartialEq)] + struct Point { + x: i32, + y: i32, + z: i32, + } + + Python::attach(|py| { + let locals = PyDict::new(py); + py.run(code, None, Some(&locals)).unwrap(); + let obj = locals.get_item("point").unwrap().unwrap(); + let err = depythonize::(&obj).unwrap_err(); + assert!(matches!( + *err.inner, + ErrorImpl::Message(msg) if msg == "missing field `z`" + )); + }); + } + + #[test] + fn test_dataclass_extra_field() { + let code = c"\ +from dataclasses import dataclass + +@dataclass +class Point: + x: int + y: int + z: int + +point = Point(1, 2, 3)"; + + #[derive(Debug, Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + struct Point { + x: i32, + y: i32, + } + + Python::attach(|py| { + let locals = PyDict::new(py); + py.run(code, None, Some(&locals)).unwrap(); + let obj = locals.get_item("point").unwrap().unwrap(); + let err = depythonize::(&obj).unwrap_err(); + assert!(matches!( + *err.inner, + ErrorImpl::Message(msg) if msg == "unknown field `z`, expected `x` or `y`" + )); + }); + } } From 0085a180619eaa64916cc2cddd2fbeccb291ab2f Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Wed, 18 Feb 2026 10:36:15 +0000 Subject: [PATCH 22/28] release: 0.28.0 (#106) --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c25d03..9fa1128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.28.0 - 2026-02-18 - Bump MSRV to 1.83. - Update `pyo3` to 0.28. diff --git a/Cargo.toml b/Cargo.toml index c4c7c78..90c1696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.27.0" +version = "0.28.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.83" From 21ad82f63053496b89c0acad8ecf7446d1d45e5a Mon Sep 17 00:00:00 2001 From: Lefty G Balogh Date: Tue, 14 Apr 2026 16:33:44 +0200 Subject: [PATCH 23/28] fix: check codepoint count not byte length in deserialize_char (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard s.len() != 1 used byte length, causing depythonize:: to return Err(InvalidLengthChar) for any non-ASCII single-codepoint character (e.g. 'ä' U+00E4 is 1 codepoint but 2 UTF-8 bytes). Fix: use s.chars().count() != 1 which counts Unicode codepoints. A test for the multibyte-codepoint case is added to de.rs. Co-authored-by: lefty --- src/de.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/de.rs b/src/de.rs index 260d625..07728b8 100644 --- a/src/de.rs +++ b/src/de.rs @@ -187,7 +187,7 @@ impl<'de> de::Deserializer<'de> for &'_ mut Depythonizer<'_, '_> { V: de::Visitor<'de>, { let s = self.input.cast::()?.to_cow()?; - if s.len() != 1 { + if s.chars().count() != 1 { return Err(PythonizeError::invalid_length_char()); } visitor.visit_char(s.chars().next().unwrap()) @@ -1017,6 +1017,19 @@ mod test { test_de(code, &expected, &expected_json); } + #[test] + fn test_char_multibyte_codepoint() { + // 'ä' is U+00E4: one Unicode codepoint, two UTF-8 bytes. + // Previously, deserialize_char checked s.len() (byte length) != 1, + // which incorrectly rejected any non-ASCII char. The fix checks + // s.chars().count() (codepoint count) != 1 instead. + Python::attach(|py| { + let py_str = pyo3::types::PyString::new(py, "ä"); + let result = depythonize::(py_str.as_any()); + assert_eq!(result.unwrap(), 'ä'); + }); + } + #[test] fn test_unknown_type() { Python::attach(|py| { From 991f3f569a2389d449e743869f37588202c6d231 Mon Sep 17 00:00:00 2001 From: Lefty G Balogh Date: Tue, 14 Apr 2026 16:35:53 +0200 Subject: [PATCH 24/28] test: add round-trip tests for collections and structs (30 tests) (#108) tests/test_collections.rs (18 tests): Vec, HashMap, BTreeMap, tuples tests/test_structs.rs (12 tests): structs, serde rename, Option, deny_unknown_fields Co-authored-by: lefty --- tests/test_collections.rs | 203 ++++++++++++++++++++++++++++++++++ tests/test_structs.rs | 222 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 tests/test_collections.rs create mode 100644 tests/test_structs.rs diff --git a/tests/test_collections.rs b/tests/test_collections.rs new file mode 100644 index 0000000..9cd7164 --- /dev/null +++ b/tests/test_collections.rs @@ -0,0 +1,203 @@ +use std::collections::{BTreeMap, HashMap}; + +use maplit::{btreemap, hashmap}; +use pyo3::prelude::*; +use pythonize::{depythonize, pythonize}; + +fn round_trip(py: Python<'_>, val: T) -> T +where + T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq, +{ + let py_val = pythonize(py, &val).expect("pythonize failed"); + depythonize(&py_val).expect("depythonize failed") +} + +// --- Vec --- + +#[test] +fn test_vec_i32_empty() { + Python::attach(|py| { + let result = round_trip(py, Vec::::new()); + assert_eq!(result, Vec::::new()); + }); +} + +#[test] +fn test_vec_i32_single() { + Python::attach(|py| { + assert_eq!(round_trip(py, vec![42i32]), vec![42i32]); + }); +} + +#[test] +fn test_vec_i32_three_elements() { + Python::attach(|py| { + assert_eq!(round_trip(py, vec![1i32, -7, 100]), vec![1i32, -7, 100]); + }); +} + +// --- Vec --- + +#[test] +fn test_vec_f64_three_elements() { + Python::attach(|py| { + let input = vec![0.0f64, -1.5, 3.14]; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} + +// --- Vec --- + +#[test] +fn test_vec_string_empty_vec() { + Python::attach(|py| { + let result = round_trip(py, Vec::::new()); + assert_eq!(result, Vec::::new()); + }); +} + +#[test] +fn test_vec_string_empty_strings() { + Python::attach(|py| { + let input = vec!["".to_string(), "".to_string()]; + assert_eq!(round_trip(py, input.clone()), input); + }); +} + +#[test] +fn test_vec_string_non_empty() { + Python::attach(|py| { + let input = vec!["hello".to_string(), "world".to_string()]; + assert_eq!(round_trip(py, input.clone()), input); + }); +} + +// --- Vec --- + +#[test] +fn test_vec_bool_mixed() { + Python::attach(|py| { + let input = vec![true, false, true, false]; + assert_eq!(round_trip(py, input.clone()), input); + }); +} + +// --- Vec> --- + +#[test] +fn test_vec_nested_two_inner_vecs() { + Python::attach(|py| { + let input = vec![vec![1i32, 2, 3], vec![-1i32, 0]]; + assert_eq!(round_trip(py, input.clone()), input); + }); +} + +// --- Tuple characterisation --- + +#[test] +fn test_tuple_i32_string_round_trip_ok() { + Python::attach(|py| { + let input = (7i32, "hello".to_string()); + let py_val = pythonize(py, &input).expect("pythonize failed"); + let result: (i32, String) = depythonize(&py_val).expect("depythonize failed"); + assert_eq!(result.0, 7i32); + assert_eq!(result.1, "hello"); + }); +} + +#[test] +fn test_tuple_bool_f64_i64_round_trip_ok() { + Python::attach(|py| { + let input = (true, 2.718f64, -42i64); + let py_val = pythonize(py, &input).expect("pythonize failed"); + let result: (bool, f64, i64) = depythonize(&py_val).expect("depythonize failed"); + assert_eq!(result.0, true); + assert_eq!(result.1, 2.718f64); + assert_eq!(result.2, -42i64); + }); +} + +// --- HashMap --- + +#[test] +fn test_hashmap_string_i64_empty() { + Python::attach(|py| { + let input: HashMap = hashmap! {}; + let result = round_trip(py, input); + assert_eq!(result, HashMap::new()); + }); +} + +#[test] +fn test_hashmap_string_i64_three_entries() { + Python::attach(|py| { + let input: HashMap = hashmap! { + "a".to_string() => 1, + "b".to_string() => -2, + "c".to_string() => 300, + }; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} + +#[test] +fn test_hashmap_string_vec_i32_two_entries() { + Python::attach(|py| { + let input: HashMap> = hashmap! { + "evens".to_string() => vec![2, 4, 6], + "odds".to_string() => vec![1, 3, 5], + }; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} + +#[test] +fn test_hashmap_string_bool_two_entries() { + Python::attach(|py| { + let input: HashMap = hashmap! { + "yes".to_string() => true, + "no".to_string() => false, + }; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} + +// --- BTreeMap --- + +#[test] +fn test_btreemap_string_i64_empty() { + Python::attach(|py| { + let input: BTreeMap = btreemap! {}; + let result = round_trip(py, input); + assert_eq!(result, BTreeMap::new()); + }); +} + +#[test] +fn test_btreemap_string_i64_three_entries() { + Python::attach(|py| { + let input: BTreeMap = btreemap! { + "alpha".to_string() => 10, + "beta".to_string() => -20, + "gamma".to_string() => 300, + }; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} + +#[test] +fn test_btreemap_string_string_two_entries() { + Python::attach(|py| { + let input: BTreeMap = btreemap! { + "key1".to_string() => "value1".to_string(), + "key2".to_string() => "value2".to_string(), + }; + let result = round_trip(py, input.clone()); + assert_eq!(result, input); + }); +} diff --git a/tests/test_structs.rs b/tests/test_structs.rs new file mode 100644 index 0000000..b00899b --- /dev/null +++ b/tests/test_structs.rs @@ -0,0 +1,222 @@ +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pythonize::{depythonize, pythonize}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Simple { + name: String, + value: i64, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct WithRename { + #[serde(rename = "firstName")] + first_name: String, + age: i32, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WithRenameAll { + first_name: String, + last_name: String, + year_of_birth: i32, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct WithOption { + label: String, + count: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Strict { + id: i32, + tag: String, +} + +// --- Simple struct --- + +#[test] +fn test_simple_round_trip() { + Python::attach(|py| { + let original = Simple { + name: "Alice".into(), + value: 42, + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: Simple = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +#[test] +fn test_simple_empty_string_zero() { + Python::attach(|py| { + let original = Simple { + name: String::new(), + value: 0, + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: Simple = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +#[test] +fn test_simple_boundary_i64_min() { + Python::attach(|py| { + let original = Simple { + name: "hello world".into(), + value: i64::MIN, + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: Simple = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +// --- #[serde(rename)] --- + +#[test] +fn test_rename_key_present() { + Python::attach(|py| { + let original = WithRename { + first_name: "Bob".into(), + age: 30, + }; + let py_obj = pythonize(py, &original).unwrap(); + let dict = py_obj.cast::().unwrap(); + assert!(dict.get_item("firstName").unwrap().is_some()); + assert!(dict.get_item("first_name").unwrap().is_none()); + }); +} + +#[test] +fn test_rename_round_trip() { + Python::attach(|py| { + let original = WithRename { + first_name: "Bob".into(), + age: 30, + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: WithRename = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +// --- #[serde(rename_all = "camelCase")] --- + +#[test] +fn test_rename_all_keys_present() { + Python::attach(|py| { + let original = WithRenameAll { + first_name: "Jane".into(), + last_name: "Doe".into(), + year_of_birth: 1990, + }; + let py_obj = pythonize(py, &original).unwrap(); + let dict = py_obj.cast::().unwrap(); + assert!(dict.get_item("firstName").unwrap().is_some()); + assert!(dict.get_item("lastName").unwrap().is_some()); + assert!(dict.get_item("yearOfBirth").unwrap().is_some()); + }); +} + +#[test] +fn test_rename_all_round_trip() { + Python::attach(|py| { + let original = WithRenameAll { + first_name: "Jane".into(), + last_name: "Doe".into(), + year_of_birth: 1990, + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: WithRenameAll = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +// --- Unknown fields (default: ignore) --- + +#[test] +fn test_unknown_fields_ignored() { + Python::attach(|py| { + let dict = PyDict::new(py); + dict.set_item("name", "test").unwrap(); + dict.set_item("value", 1i64).unwrap(); + dict.set_item("extra", "ignored").unwrap(); + let result: Result = depythonize(dict.as_any()); + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + Simple { + name: "test".into(), + value: 1 + } + ); + }); +} + +// --- #[serde(deny_unknown_fields)] --- + +#[test] +fn test_deny_unknown_fields_fails() { + Python::attach(|py| { + let dict = PyDict::new(py); + dict.set_item("id", 1i32).unwrap(); + dict.set_item("tag", "hello").unwrap(); + dict.set_item("extra", "bad").unwrap(); + let result: Result = depythonize(dict.as_any()); + assert!(result.is_err()); + }); +} + +// --- Option None --- + +#[test] +fn test_option_none_round_trip() { + Python::attach(|py| { + let original = WithOption { + label: "x".into(), + count: None, + }; + let py_obj = pythonize(py, &original).unwrap(); + let dict = py_obj.cast::().unwrap(); + let count_opt = dict.get_item("count").unwrap(); + assert!(count_opt.is_some()); + assert!(count_opt.unwrap().is_none()); + let result: WithOption = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +// --- Option Some --- + +#[test] +fn test_option_some_round_trip() { + Python::attach(|py| { + let original = WithOption { + label: "y".into(), + count: Some(99), + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: WithOption = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} + +#[test] +fn test_option_some_i64_max() { + Python::attach(|py| { + let original = WithOption { + label: "z".into(), + count: Some(i64::MAX), + }; + let py_obj = pythonize(py, &original).unwrap(); + let result: WithOption = depythonize(&py_obj).unwrap(); + assert_eq!(result, original); + }); +} From dd11ae50f7110f3885fa875008276f3732497a47 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Tue, 14 Apr 2026 15:50:31 +0100 Subject: [PATCH 25/28] support serializing i128 / u128 (#113) --- src/ser.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/ser.rs b/src/ser.rs index 844f4f0..02f3caf 100644 --- a/src/ser.rs +++ b/src/ser.rs @@ -6,7 +6,7 @@ use pyo3::types::{ PyDict, PyDictMethods, PyList, PyListMethods, PyMapping, PySequence, PyString, PyTuple, PyTupleMethods, }; -use pyo3::{Bound, BoundObject, IntoPyObject, PyAny, PyResult, Python}; +use pyo3::{Bound, IntoPyObject, IntoPyObjectExt, PyAny, PyResult, Python}; use serde::{ser, Serialize}; use crate::error::{PythonizeError, Result}; @@ -267,11 +267,8 @@ impl<'py, P: PythonizeTypes> Pythonizer<'py, P> { fn serialise_default(self, v: T) -> Result> where T: IntoPyObject<'py>, - >::Error: Into, { - v.into_pyobject(self.py) - .map(|x| x.into_any().into_bound()) - .map_err(Into::into) + v.into_bound_py_any(self.py).map_err(Into::into) } } @@ -306,6 +303,10 @@ impl<'py, P: PythonizeTypes> ser::Serializer for Pythonizer<'py, P> { self.serialise_default(v) } + fn serialize_i128(self, v: i128) -> Result> { + self.serialise_default(v) + } + fn serialize_u8(self, v: u8) -> Result> { self.serialise_default(v) } @@ -322,6 +323,10 @@ impl<'py, P: PythonizeTypes> ser::Serializer for Pythonizer<'py, P> { self.serialise_default(v) } + fn serialize_u128(self, v: u128) -> Result> { + self.serialise_default(v) + } + fn serialize_f32(self, v: f32) -> Result> { self.serialise_default(v) } @@ -874,6 +879,8 @@ mod test { f: u16, g: u32, h: u64, + i: i128, + j: u128, } test_ser( @@ -886,8 +893,10 @@ mod test { f: 6, g: 7, h: 8, + i: 9, + j: 10, }, - r#"{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8}"#, + r#"{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9,"j":10}"#, ) } From eacbad64296a85a89b99a6d595cebab3a089e8c1 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Tue, 14 Apr 2026 15:55:40 +0100 Subject: [PATCH 26/28] docs: no longer experimental (#115) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ade4de..8d1986e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pythonize -This is an experimental serializer for Rust's serde ecosystem, which can convert Rust objects to Python values and back. +This is a serializer for Rust's serde ecosystem, which can convert Rust objects to Python values and back. At the moment the Python structures it produces should be _very_ similar to those which are produced by `serde_json`; i.e. calling Python's `json.loads()` on a value encoded by `serde_json` should produce an identical structure to that which is produced directly by `pythonize`. From b07ace147514f4adf79fe5b972c2ba881dc493ba Mon Sep 17 00:00:00 2001 From: Gernot Bauer Date: Fri, 12 Jun 2026 23:53:08 +0200 Subject: [PATCH 27/28] Update to PyO3 0.29 (#117) * Update to PyO3 0.29 * update abi3 to py38 in workflow --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 3 +++ Cargo.toml | 6 +++--- README.md | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a45b1b0..04b3745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: run: cargo test --verbose - name: Test (abi3) - run: cargo test --verbose --features pyo3/abi3-py37 + run: cargo test --verbose --features pyo3/abi3-py38 - name: Test (arbitrary_precision) run: cargo test --verbose --features arbitrary_precision diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa1128..873c2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## Unreleased +- Update `pyo3` to 0.29. + ## 0.28.0 - 2026-02-18 - Bump MSRV to 1.83. diff --git a/Cargo.toml b/Cargo.toml index 90c1696..db1f21f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pythonize" -version = "0.28.0" +version = "0.29.0" authors = ["David Hewitt <1939362+davidhewitt@users.noreply.github.com>"] edition = "2021" rust-version = "1.83" @@ -14,11 +14,11 @@ documentation = "https://docs.rs/crate/pythonize/" [dependencies] serde = { version = "1.0", default-features = false, features = ["std"] } serde_json = { version = "1.0", optional = true, default-features = false, features = ["std"] } -pyo3 = { version = "0.28", default-features = false } +pyo3 = { version = "0.29", default-features = false } [dev-dependencies] serde = { version = "1.0", default-features = false, features = ["derive"] } -pyo3 = { version = "0.28", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } +pyo3 = { version = "0.29", default-features = false, features = ["auto-initialize", "macros", "py-clone"] } serde_json = { version = "1.0", default-features = false, features = ["std"] } serde_bytes = "0.11" maplit = "1.0.2" diff --git a/README.md b/README.md index 8d1986e..a59bd22 100644 --- a/README.md +++ b/README.md @@ -56,5 +56,5 @@ Enable support for `serde_json`'s `arbitrary_precision` feature, which allows ha ```toml [dependencies] -pythonize = { version = "0.28", features = ["arbitrary_precision"] } +pythonize = { version = "0.29", features = ["arbitrary_precision"] } ``` From d419510ae5f2c534a29ba190249b63132e6e13b9 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 12 Jun 2026 22:54:04 +0100 Subject: [PATCH 28/28] release: 0.29.0 (#118) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04b3745..d129ce0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false # If one platform fails, allow the rest to keep testing. matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15-dev", "3.15t-dev"] os: ["macos-latest", "ubuntu-latest", "windows-latest"] rust: [stable] include: diff --git a/CHANGELOG.md b/CHANGELOG.md index 873c2c2..b3202ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ -## Unreleased +## 0.29.0 - 2026-05-12 + - Update `pyo3` to 0.29. +- Support serializing `i128` and `u128`. ## 0.28.0 - 2026-02-18