Skip to content

Commit

Permalink
Implement evaluation of operators
Browse files Browse the repository at this point in the history
Add test suite for evaluation
Docs are up to date, some clarifications are also made
  • Loading branch information
Viicos committed May 28, 2024
1 parent 4e00a96 commit 2f340e4
Show file tree
Hide file tree
Showing 14 changed files with 505 additions and 55 deletions.
14 changes: 11 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ python-jsonlogic
:alt: PyPI - Version
:target: https://github.com/astral-sh/ruff

``python-jsonlogic`` is an extensible and sane implementation of `JsonLogic`_.
``python-jsonlogic`` is an extensible and sane implementation of `JsonLogic`_, making use of the `JSON Schema`_ specification.

.. _`JSON Schema`: https://json-schema.org/

Motivation
----------
Expand Down Expand Up @@ -80,6 +82,12 @@ Usage
print(typ)
#> BooleanType()
# 5. Apply with some data:
root_op.apply({"my_int": 3})
# 5. Evamiate with data:
from jsonlogic.evaluation import evaluate
value = evaluate(
root_op,
data={"my_int": 3},
data_schema=None,
)
print(value)
#> True
50 changes: 39 additions & 11 deletions docs/source/usage/creating_operators.rst
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
Creating operators
==================

Every operator should be defined as an implementation of the
:class:`~jsonlogic.core.Operator` abstract base class. In this example,
we will implement the ``>`` operator.
Every operator should be defined as a subclass of the :class:`~jsonlogic.core.Operator`
abstract base class. In this example, we will implement the ``>`` operator.

As the base class is defined as a :func:`~dataclasses.dataclass`,
we will follow that path for our operator.
Expand Down Expand Up @@ -64,7 +63,7 @@ This method is responsible for

from jsonlogic.typechecking import TypecheckContext
from jsonlogic.json_schema import from_value
from jsonlogic.json_schema.types import BooleanType
from jsonlogic.json_schema.types import BooleanType, UnsupportedOperation

class GreaterThan(Operator):
...
Expand All @@ -87,26 +86,55 @@ This method is responsible for
left_type = get_type(self.left, context)
right_type = get_type(self.right, context)

if not left_type.comparable_with(right_type):
try:
return left_type.binary_op(right_type, ">")
except UnsupportedOperation:
context.add_diagnostic(
f"Cannot compare {left_type.name} with {right_type.name}",
"not_comparable",
self
)
return BooleanType()

The :class:`~jsonlogic.typechecking.TypecheckContext` object is used to emit diagnostics
and access the JSON Schema of the data provided when using :func:`~jsonlogic.typechecking.typecheck`.

Implementing the :meth:`~jsonlogic.core.Operator.apply` method
--------------------------------------------------------------
Every JSON Schema type class defines two methods:
:meth:`~jsonlogic.json_schema.types.JSONSchemaType.unary_op` and :meth:`~jsonlogic.json_schema.types.JSONSchemaType.binary_op`.
The ``op`` argument is a string literal representing the Python operator, e.g. ``">"`` or ``%``.

The :meth:`~jsonlogic.core.Operator.apply` method is used to evaluate the
Implementing the :meth:`~jsonlogic.core.Operator.evaluate` method
-----------------------------------------------------------------

The :meth:`~jsonlogic.core.Operator.evaluate` method is used to evaluate the
operator.

.. todo::
Similar to the :meth:`~jsonlogic.core.Operator.typecheck` method, it is responsible for:

- evaluating the children::

from jsonlogic.evaluation import EvaluationContext, get_value

class GreaterThan(Operator):
...

def evaluate(self, context: EvaluationContext) -> bool:
left_value = get_value(self.left, context)
right_value = get_value(self.right, context)

:func:`~jsonlogic.evaluation.get_value` is a utility function to evaluate
the argument if it is an :class:`~jsonlogic.core.Operator`, or return the
primitive value.

- evaluating the current operator::

class GreaterThan(Operator):
...

def evaluate(self, context: EvaluationContext) -> bool:
left_value = get_value(self.left, context)
right_value = get_value(self.right, context)

Will need to be defined with a data stack.
return left_value > right_value


.. rubric:: Footnotes
Expand Down
87 changes: 87 additions & 0 deletions docs/source/usage/evaluation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
Evaluation
==========

Once our JSON Logic expression has been typechecked [#f1]_, it can be evaluated
using the utility :func:`~jsonlogic.evaluation.evaluate` function:

.. code-block:: python
from jsonlogic import JSONLogicExpression, Operator
from jsonlogic.evaluation import evaluate
from jsonlogic.operators import operator_registry
expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})
root_op = expr.as_operator_tree(operator_registry)
assert isinstance(root_op, Operator)
return_value = evaluate(
root_op,
data={"my_int": 1},
data_schema=None,
settings={ # Optional
"variable_casts": {...},
},
)
assert return_value is False
This function returns the evaluated expression result. Because the implementation
of the typechecking functionnality is based on the `JSON Schema`_ specification,
we assume the provided :paramref:`~jsonlogic.evaluation.evaluate.data` argument
is JSON data. When string variables are of a specific format, The
:paramref:`~jsonlogic.evaluation.evaluate.data_schema` argument is used to
know what is the corresponding format:

.. code-block:: python
expr = JSONLogicExpression.from_json({
">": [
{"var": "a_date_var"},
"2020-01-01",
]
})
root_op = expr.as_operator_tree(operator_registry)
return_value = evaluate(
root_op,
data={"a_date_var": "2024-01-01"},
data_schema={ # Use the same schema used during typechecking.
"type": "object",
"properties": {
"a_date_var": {"type": "string", "format": "date"},
},
},
settings={
"literal_casts": [date.fromisoformat],
},
)
assert return_value is True
During evaluation, variables are resolved and casted to a specific type
(see :meth:`~jsonlogic.evaluation.EvaluationContext.resolve_variable`)
according to the provided JSON Schema.

.. note::

If you are dealing with already converted data, you can pass :data:`None`
to the :paramref:`~jsonlogic.evaluation.evaluate.data_schema` argument.
This way, no variable cast will be performed during evaluation.

Evaluation settings
-------------------

Most of the available evaluation settings are analogous to the typechecking settings.
You can refer to the API documentation of the :class:`~jsonlogic.evaluation.EvaluationSettings`
class for more details.

.. _`JSON Schema`: https://json-schema.org/

.. rubric:: footnotes

.. [#f1] Of course you can skip this step and evaluate the expression directly.
Do note that no runtime exception will be caught during evaluation of operators.
5 changes: 3 additions & 2 deletions docs/source/usage/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ tree from the constructed expression:
assert registry.get("var") is Var
This allows using any operator set you'd like when evaluating an expression. ``python-jsonlogic``
provides a default set of operators, but it purposely differs from the available operators
provides a default set of operators, but it *purposely differs* from the available operators
on the `JsonLogic`_ website. In the future, a matching implementation of these operators might
be provided to ease transition from the already existing implementations.

Expand All @@ -76,7 +76,7 @@ receives two arguments:

Note that because this method is defined recursively, the retun type annotation is the union
of :class:`~jsonlogic.core.Operator` and :data:`~jsonlogic.typing.JSONLogicPrimitive`. Using
an :keyword:`assert` statement might help.
an :keyword:`assert` statement or :func:`~typing.cast` call might help your type checker.

.. warning::

Expand All @@ -92,6 +92,7 @@ The next sections will go over typechecking and evaluating the expression.
:maxdepth: 2

typechecking
evaluation
creating_operators

.. _`JsonLogic`: https://jsonlogic.com/
Expand Down
12 changes: 6 additions & 6 deletions docs/source/usage/typechecking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ can be used:
},
settings={ # Optional
"diagnostics": {"argument_type": "warning"},
}
},
)
assert root_type == BooleanType()
This function returns a two-tuple, containing:

- The type returned by the operator (see :ref:`representing types`).
- The inferred type of the operator (see :ref:`representing types`).
- The list of emitted diagnostics.

For more information on the structure of diagnostics and the related configuration,
Expand All @@ -53,8 +53,8 @@ Representing types

The :mod:`jsonlogic.json_schema.types` module defines a fixed representation of the possible
JSON Schema types. The primitive types are represented (e.g. :class:`~jsonlogic.json_schema.types.BooleanType`),
but the module extends on the different `formats <https://json-schema.org/understanding-json-schema/reference/string#format>`_
to allow operators to work with specific formats (e.g. ``"date"`` and ``"date-time"``).
but the module supports `formats <https://json-schema.org/understanding-json-schema/reference/string#format>`_
to allow operators to work with specific other types (e.g. ``"date"`` and ``"date-time"``).

Compound types
^^^^^^^^^^^^^^
Expand Down Expand Up @@ -91,7 +91,7 @@ Converting types from a ``"format"`` specifier
----------------------------------------------

The need for a ``"format"`` specifier in the `JSON Schema`_ specification comes
from the lack of these types in the JSON format.
from the lack of these types in the JSON data language.

When evaluating a JSON Logic expression, it might be beneficial to allow specific
operations on some formats:
Expand Down Expand Up @@ -199,7 +199,7 @@ the matching JSON Schema type will be returned (assuming this attribute is of ty

:attr:`~jsonlogic.typechecking.TypecheckSettings.literal_casts` is only relevant when
encountering a literal value in a JSON Logic expression. For instance, when evaluating
:json:`{">" ["2021-01-01", "2020-01-01"]}` with :attr:`~jsonlogic.typechecking.TypecheckSettings.literal_casts`
:json:`{">" ["2024-01-01", "2020-01-01"]}` with :attr:`~jsonlogic.typechecking.TypecheckSettings.literal_casts`
set to :python:`{date.fromisoformat: DateType}`, the expression will successfully typecheck
(and evaluate to :data:`True`).

Expand Down
9 changes: 5 additions & 4 deletions src/jsonlogic/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from ._compat import Self, TypeAlias
from .json_schema.types import AnyType, JSONSchemaType
Expand All @@ -16,6 +16,7 @@
# This is a hack to make Pylance think `TypeAlias` comes from `typing`
from typing import TypeAlias

from .evaluation import EvaluationContext
from .registry import OperatorRegistry
from .typechecking import TypecheckContext

Expand Down Expand Up @@ -44,9 +45,9 @@ def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Se
for checking the correct number of arguments and optionally the types.
"""

# @abstractmethod
# def evaluate(self, context: EvaluationContext) -> Any:
# """Evaluate the operator with the provided data."""
@abstractmethod
def evaluate(self, context: EvaluationContext) -> Any:
"""Evaluate the operator with the provided data."""

def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
"""Typecheck the operator (and all children) given the data schema."""
Expand Down
3 changes: 2 additions & 1 deletion src/jsonlogic/evaluation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .evaluation_context import EvaluationContext
from .evaluation_settings import EvaluationSettings
from .utils import evaluate, get_value

__all__ = ("EvaluationContext", "EvaluationSettings")
__all__ = ("EvaluationContext", "EvaluationSettings", "evaluate", "get_value")
16 changes: 8 additions & 8 deletions src/jsonlogic/evaluation/evaluation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ class should be used.
>>> expr = JSONLogicExpression.from_json({"var": "/a_date"})
>>> root_op = expr.as_operator_tree(operator_registry)
>>> context = EvaluationContext(
>>> data={"a_date": "1970-01-01"},
>>> data_schema={
>>> "type": "object",
>>> "properties": {
>>> "a_date": {"type": "string", "format": "date"},
>>> },
>>> },
>>> )
... data={"a_date": "1970-01-01"},
... data_schema={
... "type": "object",
... "properties": {
... "a_date": {"type": "string", "format": "date"},
... },
... },
... )
>>> root_op.evaluate(context)
datetime.date(1970, 1, 1)
Expand Down
60 changes: 60 additions & 0 deletions src/jsonlogic/evaluation/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

from typing import Any, Callable

from jsonlogic.core import Operator
from jsonlogic.typing import JSON, JSONLogicPrimitive, OperatorArgument

from .evaluation_context import EvaluationContext
from .evaluation_settings import EvaluationSettingsDict


def evaluate(
operator: Operator, data: JSON, data_schema: dict[str, Any] | None, settings: EvaluationSettingsDict | None = None
) -> Any:
"""Helper function to evaluate an :class:`~jsonlogic.core.Operator`.
Args:
operator: The operator to evaluate.
data: The root data available during evaluation.
data_schema: The matching JSON Schema describing the root data. This should be the same JSON Schema
used during typechecking (see :paramref:`~jsonlogic.typechecking.TypecheckContext.root_data_schema`).
settings: Settings to be used when evaluating an :class:`~jsonlogic.core.Operator`.
See :class:`EvaluationSettings` for the available settings and default values.
Returns:
The evaluated value.
"""
context = EvaluationContext(data, data_schema, settings)
return operator.evaluate(context)


# Function analogous to :func:`jsonlogic.json_schema.from_value`
def _cast_value(value: JSONLogicPrimitive, literal_casts: list[Callable[[str], Any]]) -> Any:
if isinstance(value, str):
for func in literal_casts:
try:
casted_value = func(value)
except Exception:
pass
else:
return casted_value

if not isinstance(value, list):
return value

return [_cast_value(subval, literal_casts) for subval in value]


def get_value(obj: OperatorArgument, context: EvaluationContext) -> Any:
"""Get the value of an operator argument.
Args:
obj: the object to evaluate. If this is an :class:`~jsonlogic.core.Operator`,
it is evaluated and the value is returned. Otherwise, it must be a
:data:`~jsonlogic.typing.JSONLogicPrimitive`, and the type is inferred from
the actual value according to the :attr:`~TypecheckSettings.literal_casts` setting.
context: The typecheck context.
"""
if isinstance(obj, Operator):
return obj.evaluate(context)
return _cast_value(obj, context.settings.literal_casts)
Loading

0 comments on commit 2f340e4

Please sign in to comment.