Skip to content

Commit 9d873f2

Browse files
authored
Fix mapping conversion for dict subclasses (#29)
* Fix mapping conversion for dict subclasses * Bump version to 0.5.6 * Fix dict subclass test to avoid shared state * Clarify mapping key conversion error
1 parent 8629cf9 commit 9d873f2

4 files changed

Lines changed: 116 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.5.6] - 2026-02-07
11+
12+
### Fixed
13+
14+
- Mapping conversion now respects dict subclasses and custom `Mapping` implementations, preserving `__getitem__` behavior for member access (issue #22)
15+
1016
## [0.5.5] - 2026-02-07
1117

1218
### Added

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cel"
3-
version = "0.5.5"
3+
version = "0.5.6"
44
edition = "2021"
55

66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

src/lib.rs

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use pyo3::BoundObject;
99
use std::panic::{self, AssertUnwindSafe};
1010

1111
use chrono::{DateTime, Duration as ChronoDuration, Offset, TimeZone};
12-
use pyo3::types::{PyBool, PyBytes, PyDict, PyList, PyTuple, PyType};
12+
use pyo3::types::{PyBool, PyBytes, PyDict, PyList, PyMapping, PyTuple, PyType, PyTypeMethods};
13+
use pyo3::PyTypeInfo;
1314

1415
use std::collections::HashMap;
1516
use std::error::Error;
@@ -271,6 +272,50 @@ impl fmt::Display for CelError {
271272
}
272273
impl Error for CelError {}
273274

275+
impl<'a> RustyPyType<'a> {
276+
fn key_from_py(key: &Bound<'_, PyAny>) -> Result<Key, CelError> {
277+
if key.is_none() {
278+
return Err(CelError::ConversionError(
279+
"None cannot be used as a key in dictionaries".to_string(),
280+
));
281+
}
282+
283+
if let Ok(k) = key.extract::<i64>() {
284+
Ok(Key::Int(k))
285+
} else if let Ok(k) = key.extract::<u64>() {
286+
Ok(Key::Uint(k))
287+
} else if let Ok(k) = key.extract::<bool>() {
288+
Ok(Key::Bool(k))
289+
} else if let Ok(k) = key.extract::<String>() {
290+
Ok(Key::String(k.into()))
291+
} else {
292+
Err(CelError::ConversionError(
293+
"Failed to convert Python mapping key to Key".to_string(),
294+
))
295+
}
296+
}
297+
298+
fn mapping_to_value(mapping: &Bound<'_, PyMapping>) -> Result<Value, CelError> {
299+
let keys = mapping
300+
.keys()
301+
.map_err(|e| CelError::ConversionError(format!("Failed to read mapping keys: {e}")))?;
302+
303+
let mut map: HashMap<Key, Value> = HashMap::new();
304+
for key in keys.iter() {
305+
let key_converted = Self::key_from_py(&key)?;
306+
let value = mapping.get_item(&key).map_err(|e| {
307+
CelError::ConversionError(format!("Failed to read mapping item: {e}"))
308+
})?;
309+
let value_converted = RustyPyType(&value).try_into_value().map_err(|e| {
310+
CelError::ConversionError(format!("Failed to convert mapping value: {e}"))
311+
})?;
312+
map.insert(key_converted, value_converted);
313+
}
314+
315+
Ok(Value::Map(map.into()))
316+
}
317+
}
318+
274319
/// Build a CEL execution environment from an optional evaluation context.
275320
///
276321
/// This consolidates the shared logic used by `evaluate()` and `Program.execute()`
@@ -496,34 +541,32 @@ impl TryIntoValue for RustyPyType<'_> {
496541
.collect::<Result<Vec<Value>, Self::Error>>();
497542
list.map(|v| Value::List(Arc::new(v)))
498543
} else if let Ok(value) = pyobject.cast::<PyDict>() {
499-
let mut map: HashMap<Key, Value> = HashMap::new();
500-
for (key, value) in value.into_iter() {
501-
let key = if key.is_none() {
502-
return Err(CelError::ConversionError(
503-
"None cannot be used as a key in dictionaries".to_string(),
504-
));
505-
} else if let Ok(k) = key.extract::<i64>() {
506-
Key::Int(k)
507-
} else if let Ok(k) = key.extract::<u64>() {
508-
Key::Uint(k)
509-
} else if let Ok(k) = key.extract::<bool>() {
510-
Key::Bool(k)
511-
} else if let Ok(k) = key.extract::<String>() {
512-
Key::String(k.into())
513-
} else {
514-
return Err(CelError::ConversionError(
515-
"Failed to convert PyDict key to Key".to_string(),
516-
));
517-
};
518-
if let Ok(dict_value) = RustyPyType(&value).try_into_value() {
544+
let py = pyobject.py();
545+
let is_exact_dict =
546+
pyobject.get_type().as_type_ptr() == PyDict::type_object(py).as_type_ptr();
547+
548+
if is_exact_dict {
549+
let mut map: HashMap<Key, Value> = HashMap::new();
550+
for (key, value) in value.into_iter() {
551+
let key = Self::key_from_py(&key)?;
552+
let dict_value = RustyPyType(&value).try_into_value().map_err(|e| {
553+
CelError::ConversionError(format!(
554+
"Failed to convert PyDict value to Value: {e}"
555+
))
556+
})?;
519557
map.insert(key, dict_value);
520-
} else {
521-
return Err(CelError::ConversionError(
522-
"Failed to convert PyDict value to Value".to_string(),
523-
));
524558
}
559+
Ok(Value::Map(map.into()))
560+
} else {
561+
let mapping = pyobject.cast::<PyMapping>().map_err(|e| {
562+
CelError::ConversionError(format!(
563+
"Failed to cast dict subclass to mapping: {e}"
564+
))
565+
})?;
566+
Self::mapping_to_value(mapping)
525567
}
526-
Ok(Value::Map(map.into()))
568+
} else if let Ok(mapping) = pyobject.cast::<PyMapping>() {
569+
Self::mapping_to_value(mapping)
527570
} else if let Ok(value) = pyobject.extract::<Vec<u8>>() {
528571
Ok(Value::Bytes(value.into()))
529572
} else {

tests/test_types.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import datetime
1212
import math
13+
from collections.abc import Mapping
1314

1415
import cel
1516
import pytest
@@ -218,6 +219,45 @@ def test_list_tuple_equivalence(self):
218219

219220
assert list_result == tuple_result == 2
220221

222+
def test_dict_subclass_mapping_access(self):
223+
"""Dict subclasses should resolve member access via mapping protocol."""
224+
225+
class LazyDict(dict):
226+
def __init__(self):
227+
super().__init__()
228+
self["key"] = None
229+
230+
def __getitem__(self, key):
231+
if super().__getitem__(key) is None:
232+
self[key] = "value"
233+
return super().__getitem__(key)
234+
235+
lazy = LazyDict()
236+
assert cel.evaluate("data.key", {"data": lazy}) == "value"
237+
238+
lazy_ctx = LazyDict()
239+
ctx = cel.Context(variables={"data": lazy_ctx})
240+
assert cel.evaluate("data.key", ctx) == "value"
241+
242+
def test_mapping_protocol_access(self):
243+
"""Custom Mapping implementations should resolve member access."""
244+
245+
class CustomMapping(Mapping):
246+
def __init__(self, data):
247+
self._data = data
248+
249+
def __getitem__(self, key):
250+
return self._data[key]
251+
252+
def __iter__(self):
253+
return iter(self._data)
254+
255+
def __len__(self):
256+
return len(self._data)
257+
258+
mapping = CustomMapping({"key": "value"})
259+
assert cel.evaluate("data.key", {"data": mapping}) == "value"
260+
221261

222262
class TestComplexStructures:
223263
"""Test complex and nested data structures."""

0 commit comments

Comments
 (0)