Skip to content
Open
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 .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ jobs:
- run: uv sync
- run: uv run poe test

# Ensure tests pass without optional dependencies. Issues would come from eager imports, so we go ahead
# and run the single conformance test only to keep this step lightweight and give high coverage.
- run: |
uv sync --no-dev
uv run --no-dev --group dev-required pytest -k test_conformance

lint:
runs-on: ubuntu-latest
steps:
Expand Down
18 changes: 14 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dependencies = [
"google-re2>=1.1.20251105; python_version == '3.14'",
# 1.1 started supporting 3.12.
"google-re2>=1.1; python_version == '3.12'",
"protobuf-py>=0.1.1",
"protobuf-py>=0.2.0",
]
dynamic = ["version"]

Expand All @@ -45,7 +45,14 @@ cel-expr = [
]

[dependency-groups]
# We ensure all dependencies are installed by default so IDE works properly for development.
dev = [
{ include-group = "dev-optional" },
{ include-group = "dev-required" },
]

# All dependencies required for CI and development, including running baseline tests.
dev-required = [
"buf-bin==1.71.0",
"fix-protobuf-imports==0.1.7",
"google-re2-stubs==0.1.1",
Expand All @@ -57,11 +64,14 @@ dev = [
"tombi==1.2.0",
"ty==0.0.59",
"types-protobuf==6.32.1.20260221",
]

# Optional dependencies, used for development of optional features. We run tests without
# these installed to ensure missing optional dependencies does not introduce breakage.
# Unlike most dev dependencies, we don't pin to allow testing old and new
# versions. Floored at 6.31.0 required by the cel-expr-python backend.
dev-optional = [
"cel-expr-python>=0.1.3; python_version >= '3.11'",

# Unlike most dev dependencies, we don't pin to allow testing old and new
# versions. Floored at 6.31.0 required by the cel-expr-python backend.
"protobuf>=6.31.0",
]

Expand Down
75 changes: 75 additions & 0 deletions test/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Copyright (c) 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.
from __future__ import annotations

from typing import TYPE_CHECKING, Protocol

import pytest

from protovalidate import CompilationError, Violation

if TYPE_CHECKING:
from google.protobuf import message as google_message
from protobuf import Message


class ValidatorProtocol(Protocol):
def validate(
self, message: Message | google_message.Message, *, fail_fast: bool = False
) -> None: ...

def collect_violations(
self, message: Message | google_message.Message, *, fail_fast: bool = False
) -> list[Violation]: ...


def check_valid(
validator: ValidatorProtocol, msg: Message | google_message.Message
) -> None:
# Test validate
validator.validate(msg)

# Test collect_violations
violations = validator.collect_violations(msg)
assert len(violations) == 0


def check_compilation_errors(
validator: ValidatorProtocol, msg: Message | google_message.Message, expected: str
) -> None:
"""A helper function for testing compilation errors when validating.

The tests are run using validators created via all possible methods and
validation is done via a call to `validate` as well as a call to `collect_violations`.
"""
# Test validate
with pytest.raises(CompilationError) as vce:
validator.validate(msg)
assert str(vce.value) == expected

# Test collect_violations
with pytest.raises(CompilationError) as cvce:
validator.collect_violations(msg)
assert str(cvce.value) == expected


def compare_violations(actual: list[Violation], expected: list[Violation]) -> None:
"""Compares two lists of violations. The violations are expected to be in the expected order also."""
assert len(actual) == len(expected)
for a, e in zip(actual, expected, strict=True):
assert a.proto.message == e.proto.message
assert a.proto.rule_id == e.proto.rule_id
assert a.proto.for_key == e.proto.for_key
assert a.field_value == e.field_value
assert a.rule_value == e.rule_value
21 changes: 14 additions & 7 deletions test/conformance/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,14 @@
# 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.

from __future__ import annotations

import os
import sys
from typing import TYPE_CHECKING

import celpy
import protobuf
from google.protobuf import (
descriptor_pb2 as google_descriptor_pb2,
descriptor_pool as google_descriptor_pool,
message as google_message,
message_factory as google_message_factory,
)
from protobuf import Oneof, Registry, wkt as pb_wkt

import protovalidate
Expand All @@ -37,6 +31,12 @@
TestResult,
)

if TYPE_CHECKING:
from google.protobuf import (
descriptor_pool as google_descriptor_pool,
message as google_message,
)

# Set to test google.protobuf messages instead of protobuf-py
_LEGACY = os.environ.get("PROTOVALIDATE_CONFORMANCE_LEGACY") == "1"

Expand All @@ -47,6 +47,11 @@
def build_google_pool(
fdset: pb_wkt.FileDescriptorSet,
) -> google_descriptor_pool.DescriptorPool:
from google.protobuf import ( # noqa: PLC0415
descriptor_pb2 as google_descriptor_pb2,
descriptor_pool as google_descriptor_pool,
)

pool = google_descriptor_pool.DescriptorPool()
by_name = {file.name: file for file in fdset.file}
added: set[str] = set()
Expand Down Expand Up @@ -121,6 +126,8 @@ def run_any_test_case(
)
msg = unpacked
else:
from google.protobuf import message_factory as google_message_factory # noqa: PLC0415, I001

try:
google_desc = registry.FindMessageTypeByName(type_name)
except KeyError:
Expand Down
4 changes: 4 additions & 0 deletions test/conformance/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ def test_conformance(*, legacy: bool, cel_backend: str) -> None:

env = os.environ.copy()
if legacy:
pytest.importorskip(
"google.protobuf", reason="optional dependency not installed"
)

env["PROTOVALIDATE_CONFORMANCE_LEGACY"] = "1"
env["PROTOVALIDATE_CONFORMANCE_BACKEND"] = cel_backend

Expand Down
52 changes: 26 additions & 26 deletions test/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@
import celpy
import pytest
from celpy import celtypes
from google.protobuf import text_format as google_text_format
from protobuf import Oneof
from protobuf.txtpb import message_from_text

from protovalidate import _extra_func
from protovalidate._cel_field_presence import InterpretedRunner

from .gen.cel.expr.conformance.test import simple_pb2
from .gen.cel.expr.conformance.test import simple_pb
from .versions import CEL_SPEC_VERSION

if TYPE_CHECKING:
from collections.abc import Iterable, MutableMapping

from .gen.cel.expr import eval_pb2
from .gen.cel.expr import eval_pb


skipped_tests = [
# cel-python seems to have a bug with ints and booleans in the same map which evaluate to the same value
Expand All @@ -52,37 +54,35 @@
]


def load_test_data(file_name: Path) -> simple_pb2.SimpleTestFile:
msg = simple_pb2.SimpleTestFile()
google_text_format.Parse(file_name.read_bytes(), msg)
return msg
def load_test_data(file_name: Path) -> simple_pb.SimpleTestFile:
return message_from_text(simple_pb.SimpleTestFile, file_name.read_text())


def build_variables(
bindings: MutableMapping[str, eval_pb2.ExprValue],
) -> dict[Any, Any]:
def build_variables(bindings: MutableMapping[str, eval_pb.ExprValue]) -> dict[Any, Any]:
binder = {}
for key, value in bindings.items():
if value.HasField("value"):
val = value.value
if val.HasField("string_value"):
binder[key] = celtypes.StringType(val.string_value)
match value.kind:
case Oneof("value", val):
match val.kind:
case Oneof("string_value", s):
binder[key] = celtypes.StringType(s)
return binder


def get_expected_result(test: simple_pb2.SimpleTest) -> str | None:
if test.HasField("value"):
val = test.value
if val.HasField("string_value"):
return val.string_value
def get_expected_result(test: simple_pb.SimpleTest) -> str | None:
match test.result_matcher:
case Oneof("value", val):
match val.kind:
case Oneof("string_value", s):
return s
return None


def get_eval_error_message(test: simple_pb2.SimpleTest) -> str | None:
if test.HasField("eval_error"):
err_set = test.eval_error
if len(err_set.errors) == 1:
return celtypes.StringType(err_set.errors[0].message)
def get_eval_error_message(test: simple_pb.SimpleTest) -> str | None:
match test.result_matcher:
case Oneof("eval_error", err_set):
if len(err_set.errors) == 1:
return celtypes.StringType(err_set.errors[0].message)
return None


Expand All @@ -101,11 +101,11 @@ def get_eval_error_message(test: simple_pb2.SimpleTest) -> str | None:
sections.extend(supplemental_test_data.section)

# Find the format tests which test successful formatting
_format_tests: Iterable[simple_pb2.SimpleTest] = chain.from_iterable(
_format_tests: Iterable[simple_pb.SimpleTest] = chain.from_iterable(
x.test for x in sections if x.name == "format"
)
# Find the format error tests which test errors during formatting
_format_error_tests: Iterable[simple_pb2.SimpleTest] = chain.from_iterable(
_format_error_tests: Iterable[simple_pb.SimpleTest] = chain.from_iterable(
x.test for x in sections if x.name == "format_errors"
)

Expand Down
Loading
Loading