Skip to content
Draft
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ jobs:
python-version: ${{ matrix.python-version }}
- run: uv sync
- run: uv run poe test
- run: uv run poe test-conformance

lint:
runs-on: ubuntu-latest
Expand Down
28 changes: 1 addition & 27 deletions poe_tasks.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
#:schema https://json.schemastore.org/partial-poe.json

[env]
PROTOVALIDATE_VERSION.default = "v1.2.0"

[tasks.add-license-header]
help = "Add license header to all source files"
cmd = """
Expand All @@ -28,8 +25,6 @@ help = "Run code checks"
sequence = [
"lint",
"test",
"test-conformance",
"test-conformance-legacy",
]

[tasks.diffcheck]
Expand All @@ -49,7 +44,7 @@ sequence = [
script = "scripts.generate_cel:main"

[tasks.generate-protovalidate]
script = "scripts.generate_protovalidate:main(environ['PROTOVALIDATE_VERSION'])"
script = "scripts.generate_protovalidate:main"

[tasks.generate-test]
sequence = [
Expand Down Expand Up @@ -116,27 +111,6 @@ sequence = [
{ cmd = "tombi lint" },
]

[tasks.test-conformance]
help = "Run the CEL conformance tests"
cmd = """
go run github.com/bufbuild/protovalidate/tools/protovalidate-conformance@${PROTOVALIDATE_VERSION}
--strict_message
--expected_failures=test/conformance/nonconforming.yaml
--timeout 10s
python -- -m test.conformance.runner
"""

[tasks.test-conformance-legacy]
help = "Run the CEL conformance tests through the legacy google.protobuf message path"
env = { PROTOVALIDATE_CONFORMANCE_LEGACY = "1" }
cmd = """
go run github.com/bufbuild/protovalidate/tools/protovalidate-conformance@${PROTOVALIDATE_VERSION}
--strict_message
--expected_failures=test/conformance/nonconforming.yaml
--timeout 10s
python -- -m test.conformance.runner
"""

[tasks.test]
help = "Run unit tests"
cmd = "pytest"
120 changes: 120 additions & 0 deletions protovalidate/internal/_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Copyright 2023-2026 Buf Technologies, Inc.
#
# 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
#
# http://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.

"""Backend-agnostic rule-engine primitives shared by both CEL backends."""

import abc
import typing

from protovalidate._gen.buf.validate import validate_pb


class CompilationError(Exception):
pass


class Violation:
"""A singular rule violation."""

field_value: typing.Any
rule_value: typing.Any

def __init__(
self,
*,
field_value: typing.Any = None,
rule_value: typing.Any = None,
field: validate_pb.FieldPath | None = None,
rule: validate_pb.FieldPath | None = None,
rule_id: str = "",
message: str = "",
for_key: bool = False,
):
self.field_value = field_value
self.rule_value = rule_value
self._field_elements: list[validate_pb.FieldPathElement] = list(field.elements) if field is not None else []
self._rule_elements: list[validate_pb.FieldPathElement] = list(rule.elements) if rule is not None else []
self._rule_id = rule_id
self._message = message
self._for_key = for_key

def append_field_element(self, element: validate_pb.FieldPathElement) -> None:
self._field_elements.append(element)

def extend_rule_elements(self, elements: list[validate_pb.FieldPathElement]) -> None:
self._rule_elements.extend(elements)

def finalize_paths(self) -> None:
self._field_elements.reverse()
self._rule_elements.reverse()

@property
def proto(self) -> validate_pb.Violation:
kwargs: dict[str, typing.Any] = {
"rule_id": self._rule_id,
"message": self._message,
"for_key": self._for_key,
}
if self._field_elements:
kwargs["field"] = validate_pb.FieldPath(elements=list(self._field_elements))
if self._rule_elements:
kwargs["rule"] = validate_pb.FieldPath(elements=list(self._rule_elements))
return validate_pb.Violation(**kwargs)


class RuleContext:
"""The state associated with a single rule evaluation."""

_violations: list[Violation]

def __init__(self, *, fail_fast: bool = False):
self._fail_fast = fail_fast
self._violations = []

@property
def violations(self) -> list[Violation]:
return self._violations

def add(self, violation: Violation):
self._violations.append(violation)

def add_errors(self, other_ctx: "RuleContext"):
self._violations.extend(other_ctx.violations)

def add_field_path_element(self, element: validate_pb.FieldPathElement):
for violation in self._violations:
violation.append_field_element(element)

def add_rule_path_elements(self, elements: list[validate_pb.FieldPathElement]):
for violation in self._violations:
violation.extend_rule_elements(elements)

@property
def done(self) -> bool:
return self._fail_fast and self.has_errors()

def has_errors(self) -> bool:
return len(self._violations) > 0

def sub_context(self) -> "RuleContext":
return RuleContext(fail_fast=self._fail_fast)


class Rules(abc.ABC):
"""The rules associated with a single 'rules' message."""

@abc.abstractmethod
def validate(self, ctx: RuleContext, message: typing.Any) -> None:
"""Validate the message against the rules in this rule."""
...
27 changes: 27 additions & 0 deletions protovalidate/internal/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright 2023-2026 Buf Technologies, Inc.
#
# 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
#
# http://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.

"""Which CEL backend is available.
"""

def _detect() -> bool:
try:
import cel_expr_python # noqa: F401, PLC0415
import google.protobuf.message # noqa: F401, PLC0415
except ImportError:
return False
return True


CEL_EXPR_AVAILABLE = _detect()
22 changes: 22 additions & 0 deletions protovalidate/internal/celexpr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2023-2026 Buf Technologies, Inc.
#
# 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
#
# http://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.

"""The cel-expr-python (cel-cpp) validation engine.
"""

from .bridge import GoogleBridge
from .extra_func import make_extension
from .rules import RuleFactory

__all__ = ["GoogleBridge", "RuleFactory", "make_extension"]
65 changes: 65 additions & 0 deletions protovalidate/internal/celexpr/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright 2023-2026 Buf Technologies, Inc.
#
# 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
#
# http://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.

"""Bridges protobuf-py messages into google.protobuf for cel-expr-python.
"""

from __future__ import annotations

import typing

from google.protobuf import descriptor_pb2, descriptor_pool, message, message_factory

if typing.TYPE_CHECKING:
import protobuf


class GoogleBridge:
"""Lazily mirrors protobuf-py descriptors into google's pool and bridges
protobuf-py message values to google dynamic messages."""

def __init__(self) -> None:
self._pool = descriptor_pool.Default()
self._mirrored: set[str] = set()
self._classes: dict[str, type[message.Message]] = {}

def _mirror_file(self, desc_file: typing.Any) -> None:
"""Registers a protobuf-py DescFile (and its transitive deps) into the
google pool, dependencies first, skipping files already present."""
if desc_file.name in self._mirrored:
return
self._mirrored.add(desc_file.name)
for dep in desc_file.dependencies:
self._mirror_file(dep)
try:
self._pool.FindFileByName(desc_file.name)
except KeyError:
proto = descriptor_pb2.FileDescriptorProto.FromString(desc_file.proto.to_binary())
self._pool.Add(proto)

def google_class(self, desc: typing.Any) -> type[message.Message]:
"""The google message class mirroring a protobuf-py DescMessage."""
cls = self._classes.get(desc.type_name)
if cls is None:
self._mirror_file(desc.file)
google_desc = self._pool.FindMessageTypeByName(desc.type_name)
cls = message_factory.GetMessageClass(google_desc)
self._classes[desc.type_name] = cls
return cls

def to_google(self, msg: protobuf.Message) -> message.Message:
"""Re-creates a protobuf-py message as a google.protobuf message."""
bridged = self.google_class(type(msg).desc())()
bridged.ParseFromString(msg.to_binary())
return bridged
Loading
Loading