From 75352beb524af78d706d6aa99faa20d79e7c635b Mon Sep 17 00:00:00 2001 From: Dmitri Plotnikov Date: Tue, 10 Nov 2026 14:24:13 -0800 Subject: [PATCH] [Python] Support direct equality comparison on `cel.Value`. Define `__eq__` on `PyCelValue` (`cel.Value`, inherited by `_CelListItemAccessor` and `_CelMapItemAccessor`) by delegating to `PyObject_RichCompare` on the lazily resolved Python value, unwrapping the right-hand side when comparing two `cel.Value` instances while keeping `ERROR` values distinct from plain `str` / `STRING` `cel.Value`s and `BOOL` values distinct from numeric types. Also mark `PyCelType`'s `__eq__` binding with `py::is_operator()` so comparing `cel.Type` against non-`Type` objects returns `NotImplemented` instead of raising `TypeError`. This allows `expr.eval()` and `expr.eval().value()` (including lists, maps, and indexed elements) to be compared directly against plain Python values (e.g. `assertEqual(expr.eval(), [0, 1, 2])`) without requiring `plain_value()`. PiperOrigin-RevId: 1018550229 --- README.md | 17 +++ cel_expr_python/cel.pyi | 4 +- cel_expr_python/cel_env_test.py | 7 +- cel_expr_python/cel_parallel_test.py | 6 + cel_expr_python/cel_test.py | 183 ++++++++++++++++++++++++++- cel_expr_python/py_cel_type.cc | 2 +- cel_expr_python/py_cel_value.cc | 92 ++++++++++++++ cel_expr_python/py_cel_value.h | 18 +++ release/cel_basic_test.py | 2 + 9 files changed, 325 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f25ab76..29c923a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,23 @@ Result type: BOOL Result value: True ``` +#### Comparing values + +* `==`: Compares `.value()` using Python equality (except `ERROR` values only + match equal `ERROR` values). +* `equals(other)`: Compares two `cel.Value` instances using CEL equality + (`bool != int`, cross-numeric comparisons, CEL proto equality including + `Any` unpacking, and `ERROR` returning `False`). + +```python +cel_bool = cel_env.compile("true").eval() +cel_int = cel_env.compile("1").eval() + +cel_bool == 1 # True (Python semantics) +cel_bool == cel_int # True (Python semantics) +cel_bool.equals(cel_int) # False (CEL semantics) +``` + ### Using an Activation The `eval()` function can also be invoked with an `Activation` object that diff --git a/cel_expr_python/cel.pyi b/cel_expr_python/cel.pyi index ab151bb..8695ec4 100644 --- a/cel_expr_python/cel.pyi +++ b/cel_expr_python/cel.pyi @@ -74,16 +74,18 @@ class Type: def is_assignable_from(self, arg0: Type) -> bool: ... def is_message(self) -> bool: ... def name(self) -> str: ... - def __eq__(self, arg0: Type) -> bool: ... + def __eq__(self, arg0: object) -> bool: ... BaseValue: TypeAlias = Union[None, bool, int, float, str, bytes, proto_message.Message, datetime.datetime, datetime.timedelta, Type] PlainValue: TypeAlias = Union[BaseValue, list[PlainValue], dict[PlainValue, PlainValue]] CelValue: TypeAlias = Union[BaseValue, list[Value], dict[PlainValue, Value]] class Value: + def equals(self, other: Value) -> bool: ... def plain_value(self) -> PlainValue: ... def type(self) -> Type: ... def value(self) -> Any: ... + def __eq__(self, arg0: object) -> bool: ... class _InternalArena: @staticmethod diff --git a/cel_expr_python/cel_env_test.py b/cel_expr_python/cel_env_test.py index 3212dc4..9c5e924 100644 --- a/cel_expr_python/cel_env_test.py +++ b/cel_expr_python/cel_env_test.py @@ -501,8 +501,13 @@ def test_standard_extensions_via_config(self): """) env: cel.Env = cel.NewEnv(config=config) - # Test lists extension (e.g., reverse) + # Test lists extension (e.g., range and reverse) + expr = env.compile("lists.range(3)") + self.assertEqual(expr.eval(), [0, 1, 2]) + self.assertEqual(expr.eval().value(), [0, 1, 2]) res = env.compile("[1, 2].reverse()").eval() + self.assertEqual(res, [2, 1]) + self.assertEqual(res.value(), [2, 1]) self.assertEqual(res.plain_value(), [2, 1]) # Test sets extension (e.g., intersects) diff --git a/cel_expr_python/cel_parallel_test.py b/cel_expr_python/cel_parallel_test.py index 240db88..d04b0b9 100644 --- a/cel_expr_python/cel_parallel_test.py +++ b/cel_expr_python/cel_parallel_test.py @@ -224,23 +224,29 @@ def run_concurrent_value_test(n: int): def read_values(_): # Concurrently access plain_value, value, type, and repr # on shared instances + self.assertEqual(val_scalar, n * 2) self.assertEqual(val_scalar.plain_value(), n * 2) self.assertEqual(val_scalar.value(), n * 2) self.assertEqual(val_scalar.type(), cel.Type.INT) self.assertNotEmpty(str(val_scalar)) + self.assertEqual(val_list, [n, n + 1, n + 2]) plain_list = val_list.plain_value() self.assertEqual(plain_list, [n, n + 1, n + 2]) list_accessors = val_list.value() + self.assertEqual(list_accessors, [n, n + 1, n + 2]) for idx, item in enumerate(list_accessors): + self.assertEqual(item, n + idx) self.assertEqual(item.plain_value(), n + idx) self.assertEqual(item.value(), n + idx) self.assertEqual(item.type(), cel.Type.INT) self.assertNotEmpty(str(item)) + self.assertEqual(val_map, {"key": f"k_{n}", "value": n}) plain_map = val_map.plain_value() self.assertEqual(plain_map, {"key": f"k_{n}", "value": n}) map_accessors = val_map.value() + self.assertEqual(map_accessors, {"key": f"k_{n}", "value": n}) self.assertEqual(map_accessors["key"].plain_value(), f"k_{n}") self.assertEqual(map_accessors["key"].value(), f"k_{n}") self.assertEqual(map_accessors["value"].plain_value(), n) diff --git a/cel_expr_python/cel_test.py b/cel_expr_python/cel_test.py index 39f8564..56d8b21 100644 --- a/cel_expr_python/cel_test.py +++ b/cel_expr_python/cel_test.py @@ -108,10 +108,19 @@ def testUnsetVar(self): self.assertIn( 'No value with name "var_bool" found in Activation', res.value() ) + self.assertEqual(res, self._eval("var_bool", {})) + self.assertNotEqual(res, res.value()) + self.assertNotEqual(res.value(), res) + self.assertNotEqual(res, self._eval("var_str", {"var_str": res.value()})) + self.assertFalse(res.equals(res)) + self.assertFalse(res.equals(self._eval("var_bool", {}))) + self.assertFalse(res.equals(self._eval("true"))) def testEvalNull(self): res: cel.Value = self._eval("null", expected_return_type=cel.Type.NULL) self.assertEqual(res.type(), cel.Type.NULL) + self.assertEqual(res, None) # pylint: disable=g-generic-assert + self.assertEqual(None, res) # pylint: disable=g-generic-assert self.assertIsNone(res.value()) self.assertIsNone(res.plain_value()) @@ -129,9 +138,23 @@ def testEvalNull(self): def testEvalBool(self): res: cel.Value = self._eval("true", expected_return_type=cel.Type.BOOL) self.assertEqual(res.type(), cel.Type.BOOL) + self.assertEqual(res, True) # pylint: disable=g-generic-assert + self.assertEqual(True, res) # pylint: disable=g-generic-assert + self.assertEqual(res, 1) + self.assertEqual(1, res) + self.assertEqual(res, self._eval("1")) + self.assertTrue(res.equals(self._eval("true"))) + self.assertFalse(res.equals(self._eval("1"))) + with self.assertRaises(TypeError): + non_cel_val: Any = True + res.equals(non_cel_val) self.assertEqual(res.value(), True) self.assertEqual(res.plain_value(), True) res = self._eval("false") + self.assertEqual(res, False) # pylint: disable=g-generic-assert + self.assertEqual(res, 0) + self.assertEqual(self._eval("[false]"), [0]) + self.assertFalse(self._eval("[false]").equals(self._eval("[0]"))) self.assertEqual(res.value(), False) res = self._eval("1 + 1 == 2") self.assertEqual(res.value(), True) @@ -147,6 +170,10 @@ def testEvalBool(self): def testEvalInt64(self): res: cel.Value = self._eval("42", expected_return_type=cel.Type.INT) self.assertEqual(res.type(), cel.Type.INT) + self.assertEqual(res, 42) + self.assertEqual(42, res) + self.assertNotEqual(res, 43) + self.assertEqual(res, self._eval("40 + 2")) self.assertEqual(res.value(), 42) self.assertEqual(res.plain_value(), 42) res = self._eval("1000 + 200 + 30 + 4") @@ -369,12 +396,45 @@ def testEvalList(self): "[1, 'CEL', true]", expected_return_type=cel.Type.LIST ) self.assertEqual(res.type(), cel.Type.LIST) + self.assertEqual(res, [1, "CEL", True]) + self.assertEqual([1, "CEL", True], res) + self.assertNotEqual(res, [1, "CEL", False]) + self.assertEqual(res.value(), [1, "CEL", True]) + self.assertEqual([1, "CEL", True], res.value()) + self.assertNotEqual(res.value(), [1, "CEL", False]) self.assertEqual([item.value() for item in res.value()], [1, "CEL", True]) self.assertEqual(res.plain_value(), [1, "CEL", True]) + self.assertEqual( + self._eval("[[1, 2], [3]]"), + [[1, 2], [3]], + ) + self.assertEqual( + self._eval("[[1, 2], [3]]").value(), + [[1, 2], [3]], + ) + self.assertEqual( + self._eval("[1, 2]"), + self._eval("[1, 2]"), + ) + self.assertEqual( + self._eval("[1, 2]").value(), + self._eval("[1, 2]").value(), + ) + self.assertNotEqual( + self._eval("[1, 2]"), + self._eval("[1, 3]"), + ) + self.assertNotEqual( + self._eval("[1, 2]").value(), + self._eval("[1, 3]").value(), + ) + res = self._eval("[1]", expected_return_type=cel.Type.List(cel.Type.INT)) self.assertEqual(res.type(), cel.Type.LIST) - self.assertEqual(res.value()[0].value(), 1) + self.assertEqual(res, [1]) + self.assertEqual(res.value(), [1]) + self.assertEqual(res.value()[0], 1) res = self._eval( "[" @@ -406,6 +466,31 @@ def testEvalList(self): self.assertEqual(res_list[1].type(), cel.Type.DOUBLE) self.assertAlmostEqual(res_list[1].value(), 3.14, places=3) + # An Any with an unknown type_url succeeds at field selection (producing a + # valid LIST of _CelListItemAccessors) and only fails when the element is + # lazily unpacked, exercising PyCelListItemAccessor::IsError() so error + # elements match identical errors but not plain str or STRING values. + bad_msg = test_all_types_pb.TestAllTypes() + bad_msg.repeated_any.add().type_url = ( + "type.googleapis.com/nonexistent.Message" + ) + bad_list_item = self._eval( + "var_msg.repeated_any", {"var_msg": bad_msg} + ).value()[0] + self.assertEqual(bad_list_item.type(), cel.Type.ERROR) + self.assertEqual( + bad_list_item, + self._eval("var_msg.repeated_any", {"var_msg": bad_msg}).value()[0], + ) + self.assertNotEqual(bad_list_item, bad_list_item.value()) + self.assertNotEqual(bad_list_item.value(), bad_list_item) + self.assertNotEqual( + bad_list_item, + self._eval("var_str", {"var_str": bad_list_item.value()}), + ) + self.assertFalse(bad_list_item.equals(bad_list_item)) + self.assertTrue(self._eval("[1, 2]").value()[0].equals(self._eval("1u"))) + def testEvalList_unexpectedType(self): res = self._eval("var_string_list", {"var_string_list": ["pi", 3.14]}) self.assertEqual(res.type(), cel.Type.ERROR) @@ -421,11 +506,25 @@ def testEvalMap(self): expected_return_type=cel.Type.Map(cel.Type.INT, cel.Type.STRING), ) self.assertEqual(res.type(), cel.Type.MAP) + self.assertEqual(res, {777: "CEL", 42: "Python"}) + self.assertEqual({777: "CEL", 42: "Python"}, res) + self.assertNotEqual(res, {777: "CEL", 42: "C++"}) res_map = res.value() self.assertLen(res_map, 2) - self.assertEqual(res_map[777].value(), "CEL") - self.assertEqual(res_map[42].value(), "Python") + self.assertEqual(res_map, {777: "CEL", 42: "Python"}) + self.assertEqual({777: "CEL", 42: "Python"}, res_map) + self.assertNotEqual(res_map, {777: "CEL", 42: "C++"}) + self.assertEqual(res_map[777], "CEL") + self.assertEqual(res_map[42], "Python") self.assertEqual(res.plain_value(), {777: "CEL", 42: "Python"}) + self.assertEqual( + self._eval("{'a': [1, 2]}"), + {"a": [1, 2]}, + ) + self.assertEqual( + self._eval("{'a': [1, 2]}").value(), + {"a": [1, 2]}, + ) res = self._eval( "var_int_map", @@ -439,6 +538,33 @@ def testEvalMap(self): self.assertEqual(res_map[42].value(), "Python") self.assertEqual(res.plain_value(), {777: "CEL", 42: "Python"}) + # An Any with an unknown type_url succeeds at field selection (producing a + # valid MAP whose entries are _CelMapItemAccessors) and only fails when the + # entry is lazily unpacked, exercising PyCelMapItemAccessor::IsError() so + # error entries match identical errors but not plain str or STRING values. + bad_msg = test_all_types_pb.TestAllTypes() + bad_msg.map_int64_any[1].type_url = ( + "type.googleapis.com/nonexistent.Message" + ) + bad_map_item = self._eval( + "var_msg.map_int64_any", {"var_msg": bad_msg} + ).value()[1] + self.assertEqual(bad_map_item.type(), cel.Type.ERROR) + self.assertEqual( + bad_map_item, + self._eval("var_msg.map_int64_any", {"var_msg": bad_msg}).value()[1], + ) + self.assertNotEqual(bad_map_item, bad_map_item.value()) + self.assertNotEqual(bad_map_item.value(), bad_map_item) + self.assertNotEqual( + bad_map_item, + self._eval("var_str", {"var_str": bad_map_item.value()}), + ) + self.assertFalse(bad_map_item.equals(bad_map_item)) + self.assertTrue( + self._eval("{'a': 1}").value()["a"].equals(self._eval("1.0")) + ) + res = self._eval( "var_string_map", {"var_string_map": {"CEL": True, "Python": 42}} ) @@ -660,6 +786,9 @@ def testTypeType(self): "type(1)", expected_return_type=cel.Type.Type(cel.Type.INT) ) self.assertEqual(res.type(), cel.Type.TYPE) + self.assertEqual(res, cel.Type.INT) + self.assertEqual(cel.Type.INT, res) + self.assertNotEqual(res, 1) self.assertEqual(res.value(), cel.Type.INT) res = self._eval( @@ -703,6 +832,54 @@ def testTypeType(self): ) self.assertEqual(res.value(), cel.Type.INT) + def testCelEquals(self): + # Cross-numeric comparisons work in both == and equals(). + val_int = self._eval("1") + val_uint = self._eval("1u") + val_double = self._eval("1.0") + self.assertEqual(val_int, val_uint) + self.assertEqual(val_int, val_double) + self.assertEqual(val_uint, val_double) + self.assertTrue(val_int.equals(val_uint)) + self.assertTrue(val_int.equals(val_double)) + self.assertTrue(val_uint.equals(val_double)) + self.assertFalse(self._eval("-1").equals(val_uint)) + + # Python == compares int vs float exactly, whereas CEL equals() casts + # int64/uint64 to double (matching CEL's in-language ==). + exact_int = self._eval("9007199254740993") # (1 << 53) + 1 + approx_double = self._eval("9007199254740992.0") # float(1 << 53) + self.assertNotEqual(exact_int, approx_double) + self.assertTrue(exact_int.equals(approx_double)) + + # NaN is unequal to itself in both Python and CEL. + nan_val = self._eval("0.0 / 0.0") + self.assertNotEqual(nan_val, nan_val) + self.assertFalse(nan_val.equals(nan_val)) + + # Containers apply CEL equality recursively to elements/values. + self.assertTrue(self._eval("[1, 2]").equals(self._eval("[1u, 2.0]"))) + self.assertTrue(self._eval("{'a': 1}").equals(self._eval("{'a': 1.0}"))) + self.assertFalse(self._eval("[1, 2]").equals(self._eval("[1, 3]"))) + + # Proto comparisons: == delegates to Python protobuf Message.__eq__ (raw + # Any bytes), whereas equals() uses CEL MessageEquals (unpacks Any). + msg1 = test_all_types_pb.TestAllTypes() + msg1.single_any.Pack( + test_all_types_pb.TestAllTypes(single_int32=1, single_string="a") + ) + msg2 = test_all_types_pb.TestAllTypes() + msg2.single_any.type_url = msg1.single_any.type_url + msg2.single_any.value = ( + test_all_types_pb.TestAllTypes(single_string="a").SerializeToString() + + test_all_types_pb.TestAllTypes(single_int32=1).SerializeToString() + ) + cel_msg1 = self._eval("var_msg", {"var_msg": msg1}) + cel_msg2 = self._eval("var_msg", {"var_msg": msg2}) + self.assertEqual(cel_msg1, msg1) + self.assertNotEqual(cel_msg1, cel_msg2) + self.assertTrue(cel_msg1.equals(cel_msg2)) + def testTypeInitSignature(self): self.assertEqual(cel.Type("int"), cel.Type.INT) self.assertEqual(cel.Type("list"), cel.Type.List(cel.Type.INT)) diff --git a/cel_expr_python/py_cel_type.cc b/cel_expr_python/py_cel_type.cc index 52e1750..eb4bf9a 100644 --- a/cel_expr_python/py_cel_type.cc +++ b/cel_expr_python/py_cel_type.cc @@ -65,7 +65,7 @@ void PyCelType::DefinePythonBindings(py::module& m) { .def("is_message", &PyCelType::IsMessage) .def("is_assignable_from", &PyCelType::IsAssignableFrom) .def("__repr__", &PyCelType::ToString) - .def("__eq__", &PyCelType::operator==); + .def("__eq__", &PyCelType::operator==, py::is_operator()); type.def_static("List", &PyCelType::ListType, py::arg("element_type")) .def_static("Map", &PyCelType::MapType, py::arg("key_type"), diff --git a/cel_expr_python/py_cel_value.cc b/cel_expr_python/py_cel_value.cc index 8d7aac4..ca13f77 100644 --- a/cel_expr_python/py_cel_value.cc +++ b/cel_expr_python/py_cel_value.cc @@ -74,6 +74,12 @@ void PyCelValue::DefinePythonBindings(py::module& m) { } return py::reinterpret_borrow(obj); }) + .def( + "__eq__", &PyCelValue::Eq, py::is_operator(), + R"doc(Compares using Python equality on `.value()`, keeping ERROR values distinct from non-ERROR values.)doc") + .def( + "equals", &PyCelValue::Equals, py::arg("other"), + R"doc(Compares two `Value` instances using CEL equality semantics (e.g., cross-numeric comparisons, `bool != int`, and CEL proto message equality; `ERROR` values compare unequal).)doc") .def("__repr__", &PyCelValue::ToString); py::class_(m, "_CelListItemAccessor"); @@ -116,6 +122,8 @@ PyCelValue::~PyCelValue() { PyCelType PyCelValue::Type() { return PyCelType::ForCelValue(cel_value_); } +bool PyCelValue::IsError() { return cel_value_.IsError(); } + PyObject* PyCelValue::Value() { ABSL_CHECK(PyGILState_Check()); FreeThreadingLockGuard lock(mutex_); @@ -146,6 +154,66 @@ PyObject* PyCelValue::PlainValue() { return plain_object_; } +cel::Value PyCelValue::GetCelValue() { return cel_value_; } + +// Python equality (`==`) compares converted `.value()` objects using Python's +// `__eq__` rules (so e.g. `bool` compares equal to `int`, large `int` vs +// `float` uses exact Python comparison, and protos use Python +// `Message.__eq__`), except that `ERROR` values only compare equal to `ERROR` +// values with matching messages and return `NotImplemented` against plain +// Python objects. Use `Equals()` for CEL in-language equality. Note: `Value()` +// (rather than `PlainValue()`) is used so container elements remain wrapped and +// preserve `ERROR` handling recursively. +py::object PyCelValue::Eq(py::handle other) { + ABSL_CHECK(PyGILState_Check()); + bool self_is_error = IsError(); + PyObject* other_val = other.ptr(); + if (py::isinstance(other)) { + PyCelValue& other_cel = other.cast(); + if (self_is_error != other_cel.IsError()) { + return py::reinterpret_borrow(Py_False); + } + other_val = other_cel.Value(); + if (other_val == nullptr) { + throw py::error_already_set(); + } + } else if (self_is_error) { + // Error values convert to str in Value(), so don't compare them directly + // against plain str. Returning Py_NotImplemented makes comparison with + // plain values False while still delegating to custom matchers (e.g. + // unittest.mock.ANY) via reflected __eq__. + return py::reinterpret_borrow(Py_NotImplemented); + } + PyObject* self_val = Value(); + if (self_val == nullptr) { + throw py::error_already_set(); + } + PyObject* res = PyObject_RichCompare(self_val, other_val, Py_EQ); + if (res == nullptr) { + throw py::error_already_set(); + } + return py::reinterpret_steal(res); +} + +bool PyCelValue::Equals(PyCelValue& other) { + absl::Status status; + cel::Value result; + { + // Release the GIL before calling into C++ DescriptorPool / MessageFactory + // (e.g., when resolving lazy container items or unpacking Any messages) + // to maintain the lock hierarchy (DescriptorPool Mutex -> Python GIL). + py::gil_scoped_release gil_release; + cel::Value lhs = GetCelValue(); + cel::Value rhs = other.GetCelValue(); + google::protobuf::Arena temp_arena; + status = lhs.Equal(rhs, env_->GetDescriptorPool(), + env_->GetMessageFactory(), &temp_arena, &result); + } + ThrowIfError(PyErr_toStatus()); + ThrowIfError(status); + return result.IsTrue(); +} + std::string PyCelValue::ToString() { return cel_value_.DebugString(); } PyCelValueProvider::PyCelValueProvider(std::string name, PyObject* value, @@ -208,6 +276,12 @@ PyCelType PyCelListItemAccessor::Type() { return PyCelType::ForCelValue(element_value_); } +bool PyCelListItemAccessor::IsError() { + FreeThreadingLockGuard lock(mutex_); + ResolveElementLocked(); + return element_value_.IsError(); +} + PyObject* PyCelListItemAccessor::Value() { ABSL_CHECK(PyGILState_Check()); FreeThreadingLockGuard lock(mutex_); @@ -242,6 +316,12 @@ PyObject* PyCelListItemAccessor::PlainValue() { return plain_object_; } +cel::Value PyCelListItemAccessor::GetCelValue() { + FreeThreadingLockGuard lock(mutex_); + ResolveElementLocked(); + return element_value_; +} + std::string PyCelListItemAccessor::ToString() { FreeThreadingLockGuard lock(mutex_); ResolveElementLocked(); @@ -279,6 +359,12 @@ PyCelType PyCelMapItemAccessor::Type() { return PyCelType::ForCelValue(element_value_); } +bool PyCelMapItemAccessor::IsError() { + FreeThreadingLockGuard lock(mutex_); + ResolveElementLocked(); + return element_value_.IsError(); +} + PyObject* PyCelMapItemAccessor::Value() { ABSL_CHECK(PyGILState_Check()); FreeThreadingLockGuard lock(mutex_); @@ -313,6 +399,12 @@ PyObject* PyCelMapItemAccessor::PlainValue() { return plain_object_; } +cel::Value PyCelMapItemAccessor::GetCelValue() { + FreeThreadingLockGuard lock(mutex_); + ResolveElementLocked(); + return element_value_; +} + std::string PyCelMapItemAccessor::ToString() { FreeThreadingLockGuard lock(mutex_); ResolveElementLocked(); diff --git a/cel_expr_python/py_cel_value.h b/cel_expr_python/py_cel_value.h index 5dae8e4..f82d3cc 100644 --- a/cel_expr_python/py_cel_value.h +++ b/cel_expr_python/py_cel_value.h @@ -58,8 +58,16 @@ class PyCelValue { virtual ~PyCelValue(); virtual PyCelType Type(); + // Returns true if this value represents a cel::ErrorValue. + virtual bool IsError(); virtual PyObject* Value(); virtual PyObject* PlainValue(); + // Compares using Python equality on `.value()`, keeping ERROR values distinct + // from non-ERROR values. + pybind11::object Eq(pybind11::handle other); + // Compares two cel::Values using CEL's in-language equality semantics + // (e.g., cross-numeric comparisons, bool != int, proto MessageEquals). + bool Equals(PyCelValue& other); virtual std::string ToString(); static cel::Value ToCelValue(PyObject* object, @@ -67,6 +75,8 @@ class PyCelValue { PyMessageFactory* py_message_factory); protected: + virtual cel::Value GetCelValue(); + mutable FreeThreadingMutex mutex_; cel::Value cel_value_; PyObject* object_ ABSL_GUARDED_BY(mutex_); @@ -89,10 +99,14 @@ class PyCelListItemAccessor : public PyCelValue { ~PyCelListItemAccessor() override = default; PyCelType Type() override; + bool IsError() override; PyObject* Value() override; PyObject* PlainValue() override; std::string ToString() override; + protected: + cel::Value GetCelValue() override; + private: void ResolveElementLocked() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mutex_); @@ -116,10 +130,14 @@ class PyCelMapItemAccessor : public PyCelValue { ~PyCelMapItemAccessor() override = default; PyCelType Type() override; + bool IsError() override; PyObject* Value() override; PyObject* PlainValue() override; std::string ToString() override; + protected: + cel::Value GetCelValue() override; + private: void ResolveElementLocked() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mutex_); diff --git a/release/cel_basic_test.py b/release/cel_basic_test.py index d962f42..7295243 100644 --- a/release/cel_basic_test.py +++ b/release/cel_basic_test.py @@ -66,6 +66,8 @@ def test_standard_extensions(self): # Test lists expr = env.compile("lists.range(3)") + self.assertEqual(expr.eval(), [0, 1, 2]) + self.assertEqual(expr.eval().value(), [0, 1, 2]) self.assertEqual(expr.eval().plain_value(), [0, 1, 2]) # Test math