From 0e636216d5eb9f5e0c851751d650aaf48d9de851 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Thu, 9 Oct 2025 10:23:03 +0200 Subject: [PATCH 1/2] Upgrade to PyO3 0.26 for Python 3.14 support --- Cargo.lock | 143 +++++++++++++++++++--------------------- Cargo.toml | 4 +- src/lib.rs | 190 +++++++++++++++++++++++++++-------------------------- 3 files changed, 168 insertions(+), 169 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea00a25..11059f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "0.7.18" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "canonical_json" @@ -40,12 +40,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - [[package]] name = "heck" version = "0.5.0" @@ -54,71 +48,70 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644f9158b2f133fd50f5fb3242878846d9eb792e445c893805ff0e3824006e35" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "itoa" -version = "1.0.2" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "libc" -version = "0.2.72" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f8082297d534141b30c8d39e9b1773713ab50fdbe4ff30f750d063b3bfd701" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "portable-atomic" -version = "1.7.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" -version = "0.22.6" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383" dependencies = [ - "cfg-if", "indoc", "libc", "memoffset", @@ -132,19 +125,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.22.6" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" dependencies = [ - "once_cell", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.22.6" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105" dependencies = [ "libc", "pyo3-build-config", @@ -152,43 +144,55 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.22.6" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.87", + "syn", ] [[package]] name = "pyo3-macros-backend" -version = "0.22.6" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.87", + "syn", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.5.6" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -197,15 +201,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.26" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ryu" -version = "1.0.5" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" @@ -234,7 +238,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn", ] [[package]] @@ -252,20 +256,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae548ec36cf198c0ef7710d3c230987c2d6d7bd98ad6edc0274462724c585ce" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.87" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -274,38 +267,38 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.12.16" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "thiserror" -version = "1.0.31" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 1.0.104", + "syn", ] [[package]] name = "unicode-ident" -version = "1.0.1" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unindent" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" diff --git a/Cargo.toml b/Cargo.toml index bc286f7..705c3f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,5 +17,5 @@ serde_json = "1.0" canonical_json = "0.5.0" [dependencies.pyo3] -version = "0.22.6" -features = ["extension-module", "gil-refs"] +version = "0.26.0" +features = ["extension-module"] diff --git a/src/lib.rs b/src/lib.rs index 056b715..5b2d973 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,20 @@ use canonical_json::ser::{to_string, CanonicalJSONError}; use pyo3::exceptions::PyTypeError; use pyo3::prelude::*; -use pyo3::{ - types::{PyAny, PyDict, PyFloat, PyList, PyTuple}, - wrap_pyfunction, -}; +use pyo3::types::{PyAny, PyDict, PyFloat, PyList, PyTuple}; +use pyo3::wrap_pyfunction; +use serde_json::Value as JsonValue; pub enum PyCanonicalJSONError { InvalidConversion { error: String }, PyErr { error: String }, DictKeyNotSerializable { typename: String }, - InvalidFloat { value: PyObject }, + InvalidFloat { typename: String }, InvalidCast { typename: String }, } impl From for PyCanonicalJSONError { - fn from(error: CanonicalJSONError) -> PyCanonicalJSONError { + fn from(error: CanonicalJSONError) -> Self { PyCanonicalJSONError::InvalidConversion { error: format!("{:?}", error), } @@ -23,7 +22,7 @@ impl From for PyCanonicalJSONError { } impl From for PyCanonicalJSONError { - fn from(error: pyo3::PyErr) -> PyCanonicalJSONError { + fn from(error: pyo3::PyErr) -> Self { PyCanonicalJSONError::PyErr { error: format!("{:?}", error), } @@ -34,22 +33,21 @@ impl From for pyo3::PyErr { fn from(e: PyCanonicalJSONError) -> pyo3::PyErr { match e { PyCanonicalJSONError::InvalidConversion { error } => { - PyErr::new::(format!("Conversion error: {:?}", error)) + PyErr::new::(format!("Conversion error: {error}")) } PyCanonicalJSONError::PyErr { error } => { - PyErr::new::(format!("Python Runtime exception: {}", error)) + PyErr::new::(format!("Python Runtime exception: {error}")) } PyCanonicalJSONError::DictKeyNotSerializable { typename } => { PyErr::new::(format!( - "Dictionary key is not serializable: {}", - typename + "Dictionary key is not serializable: {typename}" )) } - PyCanonicalJSONError::InvalidFloat { value } => { - PyErr::new::(format!("Invalid float: {:?}", value)) + PyCanonicalJSONError::InvalidFloat { typename } => { + PyErr::new::(format!("Invalid float (NaN/Inf): type {typename}")) } PyCanonicalJSONError::InvalidCast { typename } => { - PyErr::new::(format!("Invalid type: {}", typename)) + PyErr::new::(format!("Invalid type: {typename}")) } } } @@ -59,112 +57,120 @@ impl From for pyo3::PyErr { #[pymodule] fn canonicaljson(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; - m.add_wrapped(wrap_pyfunction!(dump))?; m.add_wrapped(wrap_pyfunction!(dumps))?; - Ok(()) } #[pyfunction] -pub fn dump(py: Python, obj: PyObject, fp: PyObject) -> PyResult { - let s = dumps(py, obj)?; - let fp_ref: &PyAny = fp.extract(py)?; - fp_ref.call_method1("write", (s,))?; - - Ok(pyo3::Python::None(py)) +pub fn dump(py: Python, obj: Py, fp: Py) -> PyResult> { + let s_obj = dumps(py, obj)?; // Py (str) + fp.bind(py).call_method1("write", (s_obj.bind(py),))?; + Ok(py.None()) // already Py } #[pyfunction] -pub fn dumps(py: Python, obj: PyObject) -> PyResult { +pub fn dumps(py: Python, obj: Py) -> PyResult> { let v = to_json(py, &obj)?; match to_string(&v) { - Ok(s) => Ok(s.to_object(py)), + Ok(s) => Ok(s.into_pyobject(py)?.unbind().into()), // Py -> Py Err(e) => Err(PyErr::new::(format!("{:?}", e))), } } -fn to_json(py: Python, obj: &PyObject) -> Result { - macro_rules! return_cast { - ($t:ty, $f:expr) => { - if let Ok(val) = obj.downcast::<$t>(py) { - return $f(val); - } - }; +fn type_name(any: &Bound<'_, PyAny>) -> PyResult { + Ok(any.get_type().name()?.to_str()?.to_string()) +} + +fn to_json(py: Python, obj: &Py) -> Result { + let any = obj.bind(py); + + // None -> JSON null + if any.is_none() { + return Ok(JsonValue::Null); } - macro_rules! return_to_value { - ($t:ty) => { - if let Ok(val) = obj.extract::<$t>(py) { - return serde_json::value::to_value(val).map_err(|error| { - PyCanonicalJSONError::InvalidConversion { - error: format!("{}", error), - } - }); + // Primitive extracts + if let Ok(s) = any.extract::() { + return Ok(JsonValue::String(s)); + } + if let Ok(b) = any.extract::() { + return Ok(JsonValue::Bool(b)); + } + if let Ok(u) = any.extract::() { + return Ok(serde_json::value::to_value(u).map_err(|e| { + PyCanonicalJSONError::InvalidConversion { + error: e.to_string(), } - }; + })?); } - - if obj.bind(py).eq(&py.None())? { - return Ok(serde_json::Value::Null); + if let Ok(i) = any.extract::() { + return Ok(serde_json::value::to_value(i).map_err(|e| { + PyCanonicalJSONError::InvalidConversion { + error: e.to_string(), + } + })?); } - return_to_value!(String); - return_to_value!(bool); - return_to_value!(u64); - return_to_value!(i64); - - return_cast!(PyDict, |x: &PyDict| { + // Dict + if let Ok(dict) = any.downcast::() { let mut map = serde_json::Map::new(); - for (key_obj, value) in x.iter() { - let key = if key_obj.eq(py.None().bind(py))? { - Ok("null".to_string()) - } else if let Ok(val) = key_obj.extract::() { - Ok(if val { - "true".to_string() + for (k_any, v_any) in dict.iter() { + // Key -> string per your rules + let key = if k_any.is_none() { + "null".to_string() + } else if let Ok(b) = k_any.extract::() { + if b { + "true".into() } else { - "false".to_string() - }) - } else if let Ok(val) = key_obj.str() { - Ok(val.to_string()) + "false".into() + } + } else if let Ok(s) = k_any.str() { + s.to_string() } else { - Err(PyCanonicalJSONError::DictKeyNotSerializable { - typename: key_obj - .to_object(py) - .bind(py) - .get_type() - .name()? - .to_string(), - }) + return Err(PyCanonicalJSONError::DictKeyNotSerializable { + typename: type_name(&k_any)?, + }); }; - map.insert(key?, to_json(py, &value.to_object(py))?); + let v_json = to_json(py, &v_any.unbind())?; + map.insert(key, v_json); } - Ok(serde_json::Value::Object(map)) - }); - - return_cast!(PyList, |x: &PyList| { - let json_array: Result, _> = - x.iter().map(|x| to_json(py, &x.to_object(py))).collect(); // This turns the iterator into a Result, PyCanonicalJSONError> - Ok(serde_json::Value::Array(json_array?)) - }); - - return_cast!(PyTuple, |x: &PyTuple| { - let json_array: Result, _> = - x.iter().map(|x| to_json(py, &x.to_object(py))).collect(); // This turns the iterator into a Result, PyCanonicalJSONError> - Ok(serde_json::Value::Array(json_array?)) - }); - - return_cast!(PyFloat, |x: &PyFloat| { - match serde_json::Number::from_f64(x.value()) { - Some(n) => Ok(serde_json::Value::Number(n)), - None => Err(PyCanonicalJSONError::InvalidFloat { - value: x.to_object(py), - }), + return Ok(JsonValue::Object(map)); + } + + // List + if let Ok(lst) = any.downcast::() { + let mut out = Vec::with_capacity(lst.len()); + for item in lst.iter() { + out.push(to_json(py, &item.unbind())?); + } + return Ok(JsonValue::Array(out)); + } + + // Tuple + if let Ok(tup) = any.downcast::() { + let mut out = Vec::with_capacity(tup.len()); + for item in tup.iter() { + out.push(to_json(py, &item.unbind())?); } - }); + return Ok(JsonValue::Array(out)); + } + + // Float (reject NaN/Inf) + if let Ok(f) = any.downcast::() { + let val = f.value(); + match serde_json::Number::from_f64(val) { + Some(n) => return Ok(JsonValue::Number(n)), + None => { + return Err(PyCanonicalJSONError::InvalidFloat { + typename: type_name(&f.as_any())?, + }) + } + } + } - // At this point we can't cast it, set up the error object + // Fallback: unsupported Err(PyCanonicalJSONError::InvalidCast { - typename: obj.bind(py).get_type().name()?.to_string(), + typename: type_name(&any)?, }) } From e3b36ca365d07c89d8a10be4faff17ac2864331e Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Thu, 9 Oct 2025 16:03:08 +0200 Subject: [PATCH 2/2] Add more tests --- tests/test_dumps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_dumps.py b/tests/test_dumps.py index 7330d4a..6e620b7 100644 --- a/tests/test_dumps.py +++ b/tests/test_dumps.py @@ -17,7 +17,8 @@ (True, "true"), ("s", '"s"'), ("é", '"\\u00e9"'), - (10.0**21, '1E21'), + (10.0**32, '1E32'), + (-10.0**21, '-1E21'), ("1\n 2 \t \b\f", '"1\\n 2 \\t \\b\\f"'), ("\xff I ❤ testing", r'"\u00ff I \u2764 testing"'), ("𝄞", r'"\ud834\udd1e"'), @@ -44,6 +45,8 @@ def __str__(self): @pytest.mark.parametrize("value,msg", [ (datetime.datetime.now(), "Invalid type: datetime"), ({Unserializable(): "a"}, "Dictionary key is not serializable: Unserializable"), + ({"a": float("inf")}, "Invalid float (NaN/Inf): type float"), + ({"a": float("nan")}, "Invalid float (NaN/Inf): type float"), ({"a": datetime.datetime.now()}, "Invalid type: datetime") ]) def test_unserializable(value, msg):