-
-
Notifications
You must be signed in to change notification settings - Fork 61
Description
Hola! Working with some rules now that I'm loading from markdown front-matter. To parse / validate the front-matter I'm using Pydantic (v2). During parsing, I wanted to natively transform a string field containing a rule-engine rule to a Rule class object. Here's the approach I took:
from typing import Any, Generic, Literal, Optional, TypeVar, cast
from pydantic import BaseModel, Field, GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema
from rule_engine import Context, DataType, Rule
from rule_engine.errors import SymbolResolutionError
from typing_extensions import get_args
SchemaType = TypeVar("SchemaType", bound=BaseModel)
class PydanticRule(Rule, Generic[SchemaType]): # type: ignore
"""
A class to store a Python `rule-engine` rule as a Pydantic model.
"""
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: GetCoreSchemaHandler,
) -> core_schema.CoreSchema:
model_fields = cast(BaseModel, get_args(_source_type)[0]).model_fields
def _python_to_rule_type(value: Any) -> DataType:
# TODO: Handle additional datatypes, complex types (unions, etc.)
try:
# check if value is a literal
if hasattr(value, "__origin__") and value.__origin__ is Literal:
return DataType.STRING
return DataType.from_type(value)
except TypeError:
return DataType.UNDEFINED
def resolve_pydantic_to_rule(field: str) -> DataType:
if field not in model_fields:
raise SymbolResolutionError(field)
return _python_to_rule_type(model_fields[field].annotation)
def validate_from_str(value: str) -> Rule:
return Rule(
value,
context=Context(type_resolver=resolve_pydantic_to_rule),
)
from_str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_from_str),
]
)
return core_schema.json_or_python_schema(
json_schema=from_str_schema,
python_schema=core_schema.union_schema(
[
# check if it's an instance first before doing any further work
core_schema.is_instance_schema(Rule),
from_str_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda instance: str(cast(Rule, instance).text)
),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
# Use the same schema that would be used for `int`
return handler(core_schema.str_schema())
# example models
class OperatingSystem(BaseModel):
vendor: str = Field(..., description="The vendor of the operating system", title="Operating system vendor")
product: str = Field(..., description="The name of the operating system", title="Operating system name")
family: Literal["linux", "windows", "macos"] = Field(
..., description="The family of the operating system", title="Operating system family"
)
version: Optional[str] = Field(
None, description="The version of the operating system", title="Operating system version"
)
arch: Optional[str] = Field(
None,
description="The architecture of the operating system, (e.g. x86_64, x86, arm64)",
title="Operating system architecture",
)
class SomeModel(BaseModel):
os: PydanticRule[OperatingSystem]
# define the rule that is read into the model
model = SomeModel.model_validate({"os": "vendor == 'Apple' and product == 'Mac OS X' and family == 'macos'"})
# test the rule against an input operating system
print(model.os.matches(OperatingSystem(vendor="Apple", product="Mac OS X", family="macos").model_dump()))The PydanticRule takes a generic type parameter that is used to define the schema supplied to Context when Rule is instantiated. This allows the benefit of syntax/symbol error detection when the rule is compiled (read into the pydantic model) instead of at runtime.
I think it's a good idea to leave pydantic out of this lib (unless folks really need it). However, it may make sense to create a separate lib that contains the types so rule-engine can be used this way.
Also, we'd probably want to spend more time on the pydantic/python -> rule-engine type conversion. I haven't fully tested that yet.