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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion cel_expr_python/cel.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion cel_expr_python/cel_env_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions cel_expr_python/cel_parallel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
183 changes: 180 additions & 3 deletions cel_expr_python/cel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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(
"["
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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}}
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<int>"), cel.Type.List(cel.Type.INT))
Expand Down
2 changes: 1 addition & 1 deletion cel_expr_python/py_cel_type.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading