From 0883e781f5f87d9a245224b8aa05c671e1cf6a10 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 7 Feb 2026 21:30:10 +1300 Subject: [PATCH 1/4] Fix mapping conversion for dict subclasses --- src/lib.rs | 95 ++++++++++++++++++++++++++++++++------------- tests/test_types.py | 39 +++++++++++++++++++ 2 files changed, 108 insertions(+), 26 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 504833b..8f1761c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,8 @@ use pyo3::BoundObject; use std::panic::{self, AssertUnwindSafe}; use chrono::{DateTime, Duration as ChronoDuration, Offset, TimeZone}; -use pyo3::types::{PyBool, PyBytes, PyDict, PyList, PyTuple, PyType}; +use pyo3::types::{PyBool, PyBytes, PyDict, PyList, PyMapping, PyTuple, PyType, PyTypeMethods}; +use pyo3::PyTypeInfo; use std::collections::HashMap; use std::error::Error; @@ -271,6 +272,50 @@ impl fmt::Display for CelError { } impl Error for CelError {} +impl<'a> RustyPyType<'a> { + fn key_from_py(key: &Bound<'_, PyAny>) -> Result { + if key.is_none() { + return Err(CelError::ConversionError( + "None cannot be used as a key in dictionaries".to_string(), + )); + } + + if let Ok(k) = key.extract::() { + Ok(Key::Int(k)) + } else if let Ok(k) = key.extract::() { + Ok(Key::Uint(k)) + } else if let Ok(k) = key.extract::() { + Ok(Key::Bool(k)) + } else if let Ok(k) = key.extract::() { + Ok(Key::String(k.into())) + } else { + Err(CelError::ConversionError( + "Failed to convert PyDict key to Key".to_string(), + )) + } + } + + fn mapping_to_value(mapping: &Bound<'_, PyMapping>) -> Result { + let keys = mapping + .keys() + .map_err(|e| CelError::ConversionError(format!("Failed to read mapping keys: {e}")))?; + + let mut map: HashMap = HashMap::new(); + for key in keys.iter() { + let key_converted = Self::key_from_py(&key)?; + let value = mapping.get_item(&key).map_err(|e| { + CelError::ConversionError(format!("Failed to read mapping item: {e}")) + })?; + let value_converted = RustyPyType(&value).try_into_value().map_err(|e| { + CelError::ConversionError(format!("Failed to convert mapping value: {e}")) + })?; + map.insert(key_converted, value_converted); + } + + Ok(Value::Map(map.into())) + } +} + /// Build a CEL execution environment from an optional evaluation context. /// /// This consolidates the shared logic used by `evaluate()` and `Program.execute()` @@ -496,34 +541,32 @@ impl TryIntoValue for RustyPyType<'_> { .collect::, Self::Error>>(); list.map(|v| Value::List(Arc::new(v))) } else if let Ok(value) = pyobject.cast::() { - let mut map: HashMap = HashMap::new(); - for (key, value) in value.into_iter() { - let key = if key.is_none() { - return Err(CelError::ConversionError( - "None cannot be used as a key in dictionaries".to_string(), - )); - } else if let Ok(k) = key.extract::() { - Key::Int(k) - } else if let Ok(k) = key.extract::() { - Key::Uint(k) - } else if let Ok(k) = key.extract::() { - Key::Bool(k) - } else if let Ok(k) = key.extract::() { - Key::String(k.into()) - } else { - return Err(CelError::ConversionError( - "Failed to convert PyDict key to Key".to_string(), - )); - }; - if let Ok(dict_value) = RustyPyType(&value).try_into_value() { + let py = pyobject.py(); + let is_exact_dict = + pyobject.get_type().as_type_ptr() == PyDict::type_object(py).as_type_ptr(); + + if is_exact_dict { + let mut map: HashMap = HashMap::new(); + for (key, value) in value.into_iter() { + let key = Self::key_from_py(&key)?; + let dict_value = RustyPyType(&value).try_into_value().map_err(|e| { + CelError::ConversionError(format!( + "Failed to convert PyDict value to Value: {e}" + )) + })?; map.insert(key, dict_value); - } else { - return Err(CelError::ConversionError( - "Failed to convert PyDict value to Value".to_string(), - )); } + Ok(Value::Map(map.into())) + } else { + let mapping = pyobject.cast::().map_err(|e| { + CelError::ConversionError(format!( + "Failed to cast dict subclass to mapping: {e}" + )) + })?; + Self::mapping_to_value(mapping) } - Ok(Value::Map(map.into())) + } else if let Ok(mapping) = pyobject.cast::() { + Self::mapping_to_value(mapping) } else if let Ok(value) = pyobject.extract::>() { Ok(Value::Bytes(value.into())) } else { diff --git a/tests/test_types.py b/tests/test_types.py index b1835fb..75cc804 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -10,6 +10,7 @@ import datetime import math +from collections.abc import Mapping import cel import pytest @@ -218,6 +219,44 @@ def test_list_tuple_equivalence(self): assert list_result == tuple_result == 2 + def test_dict_subclass_mapping_access(self): + """Dict subclasses should resolve member access via mapping protocol.""" + + class LazyDict(dict): + def __init__(self): + super().__init__() + self["key"] = None + + def __getitem__(self, key): + if super().__getitem__(key) is None: + self[key] = "value" + return super().__getitem__(key) + + lazy = LazyDict() + assert cel.evaluate("data.key", {"data": lazy}) == "value" + + ctx = cel.Context(variables={"data": lazy}) + assert cel.evaluate("data.key", ctx) == "value" + + def test_mapping_protocol_access(self): + """Custom Mapping implementations should resolve member access.""" + + class CustomMapping(Mapping): + def __init__(self, data): + self._data = data + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + mapping = CustomMapping({"key": "value"}) + assert cel.evaluate("data.key", {"data": mapping}) == "value" + class TestComplexStructures: """Test complex and nested data structures.""" From 05748a5a31d370ea9047e6a305373853191d45ab Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 7 Feb 2026 21:37:24 +1300 Subject: [PATCH 2/4] Bump version to 0.5.6 --- CHANGELOG.md | 6 ++++++ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f888f..b1c7b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.6] - 2026-02-07 + +### Fixed + +- Mapping conversion now respects dict subclasses and custom `Mapping` implementations, preserving `__getitem__` behavior for member access (issue #22) + ## [0.5.5] - 2026-02-07 ### Added diff --git a/Cargo.toml b/Cargo.toml index e03b11a..4416c12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cel" -version = "0.5.5" +version = "0.5.6" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 80353a9741971016cddfd5e7c497f9a5edfbc391 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sun, 8 Feb 2026 15:51:17 +1300 Subject: [PATCH 3/4] Fix dict subclass test to avoid shared state --- tests/test_types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_types.py b/tests/test_types.py index 75cc804..873cf24 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -235,7 +235,8 @@ def __getitem__(self, key): lazy = LazyDict() assert cel.evaluate("data.key", {"data": lazy}) == "value" - ctx = cel.Context(variables={"data": lazy}) + lazy_ctx = LazyDict() + ctx = cel.Context(variables={"data": lazy_ctx}) assert cel.evaluate("data.key", ctx) == "value" def test_mapping_protocol_access(self): From 9f571778a9930af50498583f44d220b0ed989eaf Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sun, 8 Feb 2026 15:51:33 +1300 Subject: [PATCH 4/4] Clarify mapping key conversion error --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 8f1761c..2cd6efa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -290,7 +290,7 @@ impl<'a> RustyPyType<'a> { Ok(Key::String(k.into())) } else { Err(CelError::ConversionError( - "Failed to convert PyDict key to Key".to_string(), + "Failed to convert Python mapping key to Key".to_string(), )) } }