From 2f340e404e646f05b21ff4d48d382bffb278215c Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 28 May 2024 21:03:13 +0200 Subject: [PATCH] Implement evaluation of operators Add test suite for evaluation Docs are up to date, some clarifications are also made --- README.rst | 14 +- docs/source/usage/creating_operators.rst | 50 ++++- docs/source/usage/evaluation.rst | 87 ++++++++ docs/source/usage/index.rst | 5 +- docs/source/usage/typechecking.rst | 12 +- src/jsonlogic/core.py | 9 +- src/jsonlogic/evaluation/__init__.py | 3 +- .../evaluation/evaluation_context.py | 16 +- src/jsonlogic/evaluation/utils.py | 60 +++++ src/jsonlogic/operators/operators.py | 64 ++++-- .../typechecking/typecheck_context.py | 10 +- tests/operators/test_evaluate.py | 206 ++++++++++++++++++ tests/operators/test_typecheck.py | 20 ++ tests/typechecking/test_typecheck_context.py | 4 + 14 files changed, 505 insertions(+), 55 deletions(-) create mode 100644 docs/source/usage/evaluation.rst create mode 100644 src/jsonlogic/evaluation/utils.py create mode 100644 tests/operators/test_evaluate.py diff --git a/README.rst b/README.rst index ca1c6ca..076106c 100644 --- a/README.rst +++ b/README.rst @@ -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 ---------- @@ -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 diff --git a/docs/source/usage/creating_operators.rst b/docs/source/usage/creating_operators.rst index 1fad27d..9754a3d 100644 --- a/docs/source/usage/creating_operators.rst +++ b/docs/source/usage/creating_operators.rst @@ -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. @@ -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): ... @@ -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 diff --git a/docs/source/usage/evaluation.rst b/docs/source/usage/evaluation.rst new file mode 100644 index 0000000..2e05568 --- /dev/null +++ b/docs/source/usage/evaluation.rst @@ -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. + + diff --git a/docs/source/usage/index.rst b/docs/source/usage/index.rst index 521adb7..064400c 100644 --- a/docs/source/usage/index.rst +++ b/docs/source/usage/index.rst @@ -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. @@ -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:: @@ -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/ diff --git a/docs/source/usage/typechecking.rst b/docs/source/usage/typechecking.rst index 373cbbb..4401807 100644 --- a/docs/source/usage/typechecking.rst +++ b/docs/source/usage/typechecking.rst @@ -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, @@ -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 `_ -to allow operators to work with specific formats (e.g. ``"date"`` and ``"date-time"``). +but the module supports `formats `_ +to allow operators to work with specific other types (e.g. ``"date"`` and ``"date-time"``). Compound types ^^^^^^^^^^^^^^ @@ -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: @@ -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`). diff --git a/src/jsonlogic/core.py b/src/jsonlogic/core.py index 4953ca3..f283a1e 100644 --- a/src/jsonlogic/core.py +++ b/src/jsonlogic/core.py @@ -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 @@ -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 @@ -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.""" diff --git a/src/jsonlogic/evaluation/__init__.py b/src/jsonlogic/evaluation/__init__.py index 230ac20..6e61b26 100644 --- a/src/jsonlogic/evaluation/__init__.py +++ b/src/jsonlogic/evaluation/__init__.py @@ -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") diff --git a/src/jsonlogic/evaluation/evaluation_context.py b/src/jsonlogic/evaluation/evaluation_context.py index 0558dc3..1193ffe 100644 --- a/src/jsonlogic/evaluation/evaluation_context.py +++ b/src/jsonlogic/evaluation/evaluation_context.py @@ -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) diff --git a/src/jsonlogic/evaluation/utils.py b/src/jsonlogic/evaluation/utils.py new file mode 100644 index 0000000..9a65536 --- /dev/null +++ b/src/jsonlogic/evaluation/utils.py @@ -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) diff --git a/src/jsonlogic/operators/operators.py b/src/jsonlogic/operators/operators.py index 3eeefb3..5f67496 100644 --- a/src/jsonlogic/operators/operators.py +++ b/src/jsonlogic/operators/operators.py @@ -7,7 +7,7 @@ from jsonlogic._compat import Self from jsonlogic.core import JSONLogicSyntaxError, Operator -from jsonlogic.evaluation import EvaluationContext +from jsonlogic.evaluation import EvaluationContext, get_value from jsonlogic.json_schema import as_json_schema, from_json_schema from jsonlogic.json_schema.types import ( AnyType, @@ -19,11 +19,10 @@ UnsupportedOperation, ) from jsonlogic.resolving import Unresolvable +from jsonlogic.typechecking import TypecheckContext, get_type from jsonlogic.typing import OperatorArgument from jsonlogic.utils import UNSET, UnsetType -from ..typechecking import TypecheckContext, get_type - @dataclass class Var(Operator): @@ -81,10 +80,13 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: return js_type def evaluate(self, context: EvaluationContext) -> Any: - pass - # str_path = ( - # self.variable_path.evaluate(context) if isinstance(self.variable_path, Operator) else self.variable_path - # ) + str_path = get_value(self.variable_path, context) + try: + return context.resolve_variable(str_path) + except Exception: # TODO have a proper exception raised by `resolve_variable` + if self.default_value is UNSET: + raise + return get_value(self.default_value, context) @dataclass @@ -108,6 +110,12 @@ def typecheck(self, context: TypecheckContext) -> BooleanType: self.right.typecheck(context) return BooleanType() + def evaluate(self, context: EvaluationContext) -> bool: + left = get_value(self.left, context) + right = get_value(self.right, context) + + return self.equality_func(left, right) + @dataclass class Equal(EqualityOperator): @@ -146,6 +154,13 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: self.leading_else, context ) + def evaluate(self, context: EvaluationContext) -> Any: + for cond, rv in self.if_elses: + if get_value(cond, context): + return get_value(rv, context) + + return get_value(self.leading_else, context) + @dataclass class BinaryOperator(Operator): @@ -178,15 +193,10 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: return AnyType() def evaluate(self, context: EvaluationContext) -> bool: - return True - # left = self.left - # if isinstance(left, Operator): - # left = left.evaluate(context) - # right = self.right - # if isinstance(right, Operator): - # right = right.evaluate(context) + left = get_value(self.left, context) + right = get_value(self.right, context) - # return self.operator_func(left, right) + return self.operator_func(left, right) @dataclass @@ -257,6 +267,9 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: return result_type + def evaluate(self, context: EvaluationContext) -> Any: + return functools.reduce(lambda a, b: get_value(a, context) + get_value(b, context), self.arguments) + @dataclass class Minus(Operator): @@ -297,6 +310,14 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: ) return AnyType() + def evaluate(self, context: EvaluationContext) -> Any: + left_value = get_value(self.left, context) + if self.right is UNSET: + return -left_value + + right_value = get_value(self.right, context) + return left_value - right_value + @dataclass class Map(Operator): @@ -323,3 +344,16 @@ def typecheck(self, context: TypecheckContext) -> JSONSchemaType: func_type = get_type(self.func, context) return ArrayType(func_type) + + def evaluate(self, context: EvaluationContext) -> list[Any]: + vars_value = get_value(self.vars, context) + + # `vars_value` is already evaluated, and any required literal/variable cast is done. + # Thus no data schema is passed to the data stack, and `EvaluationContext.resolve_variable` + # will assume the bare value should be returned. + return_array: list[Any] = [] + for var in vars_value: + with context.data_stack.push((var, None)): + return_array.append(get_value(self.func, context)) + + return return_array diff --git a/src/jsonlogic/typechecking/typecheck_context.py b/src/jsonlogic/typechecking/typecheck_context.py index aebb16c..8ed0c33 100644 --- a/src/jsonlogic/typechecking/typecheck_context.py +++ b/src/jsonlogic/typechecking/typecheck_context.py @@ -21,11 +21,11 @@ class should be used. >>> expr = ... >>> root_op = expr.as_operator_tree(operator_registry) >>> context = TypecheckContext( - >>> root_data_schema={"type": "object", "properties": ...}, - >>> settings={ - >>> "diagnostics": {"argument_type": "warning"}, - >>> }, - >>> ) + ... root_data_schema={"type": "object", "properties": ...}, + ... settings={ + ... "diagnostics": {"argument_type": "warning"}, + ... }, + ... ) >>> root_op.typecheck(context) >>> context.diagnostics [Diagnostic(message="...", ...)] diff --git a/tests/operators/test_evaluate.py b/tests/operators/test_evaluate.py new file mode 100644 index 0000000..5495da9 --- /dev/null +++ b/tests/operators/test_evaluate.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Any, cast + +import pytest + +from jsonlogic._compat import Self +from jsonlogic.core import JSONLogicExpression, Operator +from jsonlogic.evaluation import EvaluationContext, evaluate +from jsonlogic.operators import operator_registry as base_operator_registry +from jsonlogic.resolving import Unresolvable +from jsonlogic.typing import OperatorArgument + + +@dataclass +class ReturnsOp(Operator): + """A test operator returning the provided value during evaluation.""" + + return_value: Any + + @classmethod + def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self: + assert len(arguments) == 1 + return cls( + operator=operator, + return_value=arguments[0], + ) + + def evaluate(self, context: EvaluationContext) -> Any: + return self.return_value + + +operator_registry = base_operator_registry.copy(extend={"returns": ReturnsOp}) + + +def as_op(json_logic: dict[str, Any]) -> Operator: + expr = JSONLogicExpression.from_json(json_logic) + return cast(Operator, expr.as_operator_tree(operator_registry)) + + +def test_var_dynamic_variable_path() -> None: + op = as_op({"var": {"returns": "/some_var"}}) + rv = evaluate( + op, + data={"some_var": 1}, + data_schema=None, + ) + assert rv == 1 + + +def test_var_unresolvable_no_default() -> None: + op = as_op({"var": "/some_var"}) + + with pytest.raises(Unresolvable) as exc: + evaluate(op, data={}, data_schema=None) + + assert exc.value.parsed_reference.reference == "/some_var" + + +def test_var_unresolvable_default() -> None: + op = as_op({"var": ["/some_var", 1]}) + rv = evaluate( + op, + data={}, + data_schema=None, + ) + + assert rv == 1 + + +def test_var_resolvable_default() -> None: + op = as_op({"var": ["/some_var", 1]}) + rv = evaluate( + op, + data={"some_var": "a"}, + data_schema=None, + ) + + assert rv == "a" + + +def test_equal_op() -> None: + op_1 = as_op({"==": [1, "test"]}) + rv = evaluate( + op_1, + data={}, + data_schema=None, + ) + + assert rv is False + + +def test_not_equal_op() -> None: + op_1 = as_op({"!=": [1, "test"]}) + rv = evaluate( + op_1, + data={}, + data_schema=None, + ) + + assert rv is True + + +def test_if() -> None: + op = as_op({"if": [False, "string_val", 1, 2.0, "other_string"]}) + rv = evaluate( + op, + data={}, + data_schema=None, + ) + + assert rv == 2.0 + + +def test_if_uses_leading_else() -> None: + op = as_op({"if": [False, "string_val", 0, 2.0, "other_string"]}) + rv = evaluate( + op, + data={}, + data_schema=None, + ) + + assert rv == "other_string" + + +def test_binary_op() -> None: + # Note: this test is relevant for all the binary operator classes. + op = as_op({">": [2, 1]}) + rv = evaluate( + op, + data={}, + data_schema=None, + ) + + assert rv is True + + +def test_plus() -> None: + op_two_operands = as_op({"+": [1, 2]}) + rv = evaluate( + op_two_operands, + data={}, + data_schema=None, + ) + + assert rv == 3 + + op_three_operands = as_op({"+": [1, 2, 3]}) + rv = evaluate( + op_three_operands, + data={}, + data_schema=None, + ) + + assert rv == 6 + + +def test_minus() -> None: + op_unary = as_op({"-": 1}) + rv = evaluate( + op_unary, + data={}, + data_schema=None, + ) + + assert rv == -1 + + op_binary = as_op({"-": [2, 1]}) + rv = evaluate( + op_binary, + data={}, + data_schema=None, + ) + + assert rv == 1 + + +def test_map() -> None: + op = as_op({"map": [[1, 2], {"+": [{"var": ""}, 2.0]}]}) + rv = evaluate(op, {}, None) + + assert rv == [3.0, 4.0] + + +@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.") +def test_map_op_in_values(): + op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]}) + + rv = evaluate( + op, + data={"my_date": "1960-01-01"}, + data_schema={"type": "object", "properties": {"my_date": {"type": "string", "format": "date"}}}, + settings={"literal_casts": [date.fromisoformat]}, + ) + + assert rv == [True, False] + + +def test_map_root_reference() -> None: + # The `/@1` reference should resolve to the "" attribute of the top level schema, + # meaning the variables of the `map` operators are meaningless. + op = as_op({"map": [["some", "strings"], {"+": [{"var": "/@1"}, 2.0]}]}) + rv = evaluate(op, data={"": 1}, data_schema={"type": "object", "properties": {"": {"type": "integer"}}}) + assert rv == [3.0, 3.0] diff --git a/tests/operators/test_typecheck.py b/tests/operators/test_typecheck.py index ea86e25..617b319 100644 --- a/tests/operators/test_typecheck.py +++ b/tests/operators/test_typecheck.py @@ -1,10 +1,14 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import date from typing import Any, cast +import pytest + from jsonlogic._compat import Self from jsonlogic.core import JSONLogicExpression, Operator +from jsonlogic.evaluation import EvaluationContext from jsonlogic.json_schema.types import ( AnyType, ArrayType, @@ -38,6 +42,9 @@ def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Se def typecheck(self, context: TypecheckContext) -> JSONSchemaType: return self.return_type + def evaluate(self, context: EvaluationContext) -> None: + return None + operator_registry = base_operator_registry.copy(extend={"returns": ReturnsOp}) @@ -225,6 +232,19 @@ def test_map() -> None: assert diagnostics == [] +@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.") +def test_map_op_in_values(): + op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]}) + + rt, _ = typecheck( + op, + data_schema={"type": "object", "properties": {"my_date": {"type": "string", "format": "date"}}}, + settings={"literal_casts": {date.fromisoformat: DateType}}, + ) + + assert rt == ArrayType(BooleanType()) + + def test_map_root_reference() -> None: # The `/@1` reference should resolve to the "" attribute of the top level schema, # meaning the variables of the `map` operators are meaningless. diff --git a/tests/typechecking/test_typecheck_context.py b/tests/typechecking/test_typecheck_context.py index 67f5e15..1cbc609 100644 --- a/tests/typechecking/test_typecheck_context.py +++ b/tests/typechecking/test_typecheck_context.py @@ -2,6 +2,7 @@ from jsonlogic._compat import Self from jsonlogic.core import Operator +from jsonlogic.evaluation.evaluation_context import EvaluationContext from jsonlogic.typechecking import Diagnostic, TypecheckContext from jsonlogic.typing import OperatorArgument @@ -11,6 +12,9 @@ class DummyOp(Operator): def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self: return cls(operator) + def evaluate(self, context: EvaluationContext) -> None: + return None + def test_typecheck_context(): context = TypecheckContext({"root": "schema"}, settings={"diagnostics": {"general": "warning"}})