Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
95 changes: 69 additions & 26 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Key, CelError> {
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::<i64>() {
Ok(Key::Int(k))
} else if let Ok(k) = key.extract::<u64>() {
Ok(Key::Uint(k))
} else if let Ok(k) = key.extract::<bool>() {
Ok(Key::Bool(k))
} else if let Ok(k) = key.extract::<String>() {
Ok(Key::String(k.into()))
} else {
Err(CelError::ConversionError(
"Failed to convert Python mapping key to Key".to_string(),
))
}
}

fn mapping_to_value(mapping: &Bound<'_, PyMapping>) -> Result<Value, CelError> {
let keys = mapping
.keys()
.map_err(|e| CelError::ConversionError(format!("Failed to read mapping keys: {e}")))?;

let mut map: HashMap<Key, Value> = 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()`
Expand Down Expand Up @@ -496,34 +541,32 @@ impl TryIntoValue for RustyPyType<'_> {
.collect::<Result<Vec<Value>, Self::Error>>();
list.map(|v| Value::List(Arc::new(v)))
} else if let Ok(value) = pyobject.cast::<PyDict>() {
let mut map: HashMap<Key, Value> = 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::<i64>() {
Key::Int(k)
} else if let Ok(k) = key.extract::<u64>() {
Key::Uint(k)
} else if let Ok(k) = key.extract::<bool>() {
Key::Bool(k)
} else if let Ok(k) = key.extract::<String>() {
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<Key, Value> = 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::<PyMapping>().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::<PyMapping>() {
Self::mapping_to_value(mapping)
} else if let Ok(value) = pyobject.extract::<Vec<u8>>() {
Ok(Value::Bytes(value.into()))
} else {
Expand Down
40 changes: 40 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import datetime
import math
from collections.abc import Mapping

import cel
import pytest
Expand Down Expand Up @@ -218,6 +219,45 @@ 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"

lazy_ctx = LazyDict()
ctx = cel.Context(variables={"data": lazy_ctx})
assert cel.evaluate("data.key", ctx) == "value"
Comment thread
hardbyte marked this conversation as resolved.

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."""
Expand Down