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
5 changes: 5 additions & 0 deletions cel_expr_python/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pybind_library(
"py_message_factory.cc",
],
hdrs = [
"free_threading_mutex.h",
"py_cel_activation.h",
"py_cel_arena.h",
"py_cel_env.h",
Expand All @@ -46,6 +47,7 @@ pybind_library(
"py_descriptor_database.h",
"py_message_factory.h",
],
tags = ["freethreading_compatible"],
visibility = [":__subpackages__"],
deps = [
":cel_extension",
Expand Down Expand Up @@ -113,6 +115,7 @@ pybind_extension(
"//visibility:public",
],
deps = [
":cel_extension",
":cel_pybind_lib",
],
)
Expand All @@ -127,6 +130,7 @@ pybind_library(
"cel_extension.h",
"py_error_status.h",
],
tags = ["freethreading_compatible"],
visibility = ["//visibility:public"],
deps = [
":status_macros",
Expand Down Expand Up @@ -161,6 +165,7 @@ py_test(
"//testing:proto2_test_all_types_py_pb2",
"@com_google_absl_py//absl/testing:absltest",
"@com_google_protobuf//:protobuf",
"@com_google_protobuf//:protobuf_python",
] + select({
"@platforms//os:windows": [],
"//conditions:default": [":cel"],
Expand Down
4 changes: 2 additions & 2 deletions cel_expr_python/cel_extension.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ class CelExtension {
// CEL_EXTENSION_MODULE(sample_cel_ext, SampleCelExtension);
//
#define CEL_EXTENSION_MODULE(module_name, class_name) \
PYBIND11_MODULE(module_name, m) { \
PYBIND11_MODULE(module_name, m, pybind11::mod_gil_not_used()) { \
pybind11::module_::import(CEL_MODULE_NAME); \
pybind11::class_<class_name, cel_python::CelExtension>(m, #class_name) \
.def(pybind11::init<>()); \
}

#define CEL_VERSIONED_EXTENSION_MODULE(module_name, class_name) \
PYBIND11_MODULE(module_name, m) { \
PYBIND11_MODULE(module_name, m, pybind11::mod_gil_not_used()) { \
pybind11::module_::import(CEL_MODULE_NAME); \
pybind11::class_<class_name, cel_python::CelExtension>(m, #class_name) \
.def(pybind11::init<>()) \
Expand Down
41 changes: 41 additions & 0 deletions cel_expr_python/cel_parallel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,47 @@ def testMultiThreadedCompilation(self):
def testSequentialCompilation(self):
self._test_compile(multi_threaded=False)

def testSharedValueConcurrentAccess(self):
expr_scalar = self.env.compile("var_int * 2")
expr_list = self.env.compile("[var_int, var_int + 1, var_int + 2]")
expr_map = self.env.compile("{'key': var_str, 'value': var_int}")

def run_concurrent_value_test(n: int):
val_scalar = expr_scalar.eval(data={"var_int": n})
val_list = expr_list.eval(data={"var_int": n})
val_map = expr_map.eval(data={"var_str": f"k_{n}", "var_int": n})

def read_values(_):
# Concurrently access plain_value, value, type, and repr
# on shared instances
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))

plain_list = val_list.plain_value()
self.assertEqual(plain_list, [n, n + 1, n + 2])
list_accessors = val_list.value()
for idx, item in enumerate(list_accessors):
self.assertEqual(item.plain_value(), n + idx)
self.assertEqual(item.value(), n + idx)
self.assertEqual(item.type(), cel.Type.INT)
self.assertNotEmpty(str(item))

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"].plain_value(), f"k_{n}")
self.assertEqual(map_accessors["key"].value(), f"k_{n}")
self.assertEqual(map_accessors["value"].plain_value(), n)
self.assertEqual(map_accessors["value"].value(), n)

with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(read_values, range(50)))

for i in range(10):
run_concurrent_value_test(i)


if __name__ == "__main__":
absltest.main()
27 changes: 27 additions & 0 deletions cel_expr_python/cel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import sys
from typing import Any

from google.protobuf import message_factory
from google.protobuf import duration_pb2 as duration_pb
from google.protobuf import timestamp_pb2 as timestamp_pb
from absl.testing import absltest
Expand Down Expand Up @@ -337,6 +338,32 @@ def testProto_unexpectedType(self):
r" .*. \(Expected cel.expr.conformance.proto2.TestAllTypes\)",
)

def testProto_cannotFindMessageClass(self):
orig_get_message_class = message_factory.GetMessageClass

def failing_get_message_class(descriptor):
del descriptor
raise TypeError(
"Couldn't build proto class because dependency X is missing"
)

try:
message_factory.GetMessageClass = failing_get_message_class
env = cel.NewEnv(options=self.options)
expr = env.compile(
"cel.expr.conformance.proto2.TestAllTypes{single_string: 'hello'}"
)
res = expr.eval(env.Activation())
with self.assertRaisesRegex(
TypeError, "Couldn't find message class for type"
):
res.plain_value()
del res
del expr
del env
finally:
message_factory.GetMessageClass = orig_get_message_class

def testEvalList(self):
res: cel.Value = self._eval(
"[1, 'CEL', true]", expected_return_type=cel.Type.LIST
Expand Down
85 changes: 85 additions & 0 deletions cel_expr_python/free_threading_mutex.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#ifndef THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_
#define THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_

#include <Python.h> // IWYU pragma: keep - Needed for Py_GIL_DISABLED

#include "absl/base/attributes.h"
#include "absl/base/thread_annotations.h"

#ifndef Py_GIL_DISABLED
#else
#include "absl/synchronization/mutex.h"
#endif

namespace cel_python {

// Zero-cost mutex wrapper that compiles away to nothing in standard GIL builds,
// and uses absl::Mutex in free-threaded builds (Py_GIL_DISABLED).
class ABSL_LOCKABLE ABSL_ATTRIBUTE_WARN_UNUSED FreeThreadingMutex {
public:
FreeThreadingMutex() = default;
FreeThreadingMutex(const FreeThreadingMutex&) = delete;
FreeThreadingMutex& operator=(const FreeThreadingMutex&) = delete;

#ifndef Py_GIL_DISABLED
// In GIL-enabled builds, this mutex compiles away to zero-cost no-ops while
// retaining thread-safety annotations for Clang static analysis.
//
// Relationship with the GIL:
// - For operations accessing Python state or cached PyObjects (such as
// PyCelValue::Value() or PyMessageFactory::GetMessageClass()), mutual
// exclusion between threads is provided by the Python GIL itself.
// - For pure C++ operations (such as PyCelEnv::Compile() or deserialization),
// the GIL is intentionally released (via py::gil_scoped_release) to enable
// parallel execution and prevent deadlocks with DescriptorPool's mutex.
// Objects may therefore be constructed or moved while the GIL is NOT held.
// - Consequently, Lock() and Unlock() do not assert PyGILState_Check(),
// allowing lock acquisition and move semantics to function safely whether
// the GIL is currently held or released. Call sites that strictly require
// the GIL explicitly verify or acquire it themselves.
void Lock() ABSL_EXCLUSIVE_LOCK_FUNCTION() {}
void Unlock() ABSL_UNLOCK_FUNCTION() {}
#else
// Free-threaded build: real mutex
void Lock() ABSL_EXCLUSIVE_LOCK_FUNCTION() { mutex_.Lock(); }
void Unlock() ABSL_UNLOCK_FUNCTION() { mutex_.Unlock(); }

private:
absl::Mutex mutex_;
#endif
};

// RAII lock guard for FreeThreadingMutex.
class ABSL_SCOPED_LOCKABLE FreeThreadingLockGuard {
public:
explicit FreeThreadingLockGuard(FreeThreadingMutex& mutex)
ABSL_EXCLUSIVE_LOCK_FUNCTION(mutex)
: mutex_(mutex) {
mutex_.Lock();
}
~FreeThreadingLockGuard() ABSL_UNLOCK_FUNCTION() { mutex_.Unlock(); }

FreeThreadingLockGuard(const FreeThreadingLockGuard&) = delete;
FreeThreadingLockGuard& operator=(const FreeThreadingLockGuard&) = delete;

private:
FreeThreadingMutex& mutex_;
};

} // namespace cel_python

#endif // THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_
1 change: 1 addition & 0 deletions cel_expr_python/py_cel_env.cc
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ PyCelExpression PyCelEnv::Compile(const std::string& cel_expr,
}

PyCelExpression PyCelEnv::Deserialize(const std::string& serialized_expr) {
py::gil_scoped_release gil_release;
return ThrowIfError(PyCelExpression::Deserialize(env_, serialized_expr));
}

Expand Down
Loading
Loading