Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for nested operators inside arrays #25

Merged
merged 1 commit into from
Jun 2, 2024
Merged
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
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
project = "python-jsonlogic"
copyright = "2024, Victorien"
author = "Victorien"
release = "0.1"
release = "0.0.1"

# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
Expand Down
1 change: 0 additions & 1 deletion docs/source/usage/evaluation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ using the utility :func:`~jsonlogic.evaluation.evaluate` function:
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,
Expand Down
4 changes: 0 additions & 4 deletions docs/source/usage/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ receives two arguments:
- :paramref:`~jsonlogic.core.Operator.from_expression.arguments`: The list of arguments for this operator.
This can either be another :class:`~jsonlogic.core.Operator` or a :data:`~jsonlogic.typing.JSONLogicPrimitive`.

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 or :func:`~typing.cast` call might help your type checker.

.. warning::

Each operator is responsible for checking the provided arguments. For example, the ``GreaterThan`` operator
Expand Down
6 changes: 3 additions & 3 deletions docs/source/usage/resolving_variables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ the expression will evaluate to :json:`1`. However, this dot-like notation can b
"some.var": 2
}

For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`). The following expressions:
For this reason, an alternative format is proposed, based on the JSON Pointer standard (:rfc:`6901`).

with the following data:
With the following data:

.. code-block:: json

Expand All @@ -71,7 +71,7 @@ this is how the references will evaluate:
Variables scopes
----------------

The original `JsonLogic`_ format implicitly uses the notion scope in the implementation
The original `JsonLogic`_ format implicitly uses the notion of a scope in the implementation
of some operators such as `map <https://jsonlogic.com/operations.html#map-reduce-and-filter>`_:

.. code-block:: json
Expand Down
1 change: 0 additions & 1 deletion docs/source/usage/typechecking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ can be used:
expr = JSONLogicExpression.from_json({">": [{"var": "my_int"}, 2]})

root_op = expr.as_operator_tree(operator_registry)
assert isinstance(root_op, Operator)

root_type, diagnostics = typecheck(
root_op,
Expand Down
44 changes: 28 additions & 16 deletions src/jsonlogic/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from ._compat import Self, TypeAlias
from .json_schema.types import AnyType, JSONSchemaType
from .typing import JSON, JSONLogicPrimitive, OperatorArgument
from .typing import JSON, JSONLogicPrimitive, JSONObject, OperatorArgument

if TYPE_CHECKING:
# This is a hack to make Pylance think `TypeAlias` comes from `typing`
Expand Down Expand Up @@ -62,27 +62,35 @@ def __init__(self, message: str, /) -> None:
self.message = message


NormalizedExpression: TypeAlias = "dict[str, list[JSONLogicExpression]]"
ExprArgument: TypeAlias = "JSONLogicPrimitive | JSONLogicExpression | list[ExprArgument]"

NormalizedExpression: TypeAlias = "dict[str, list[ExprArgument]]"


@dataclass
class JSONLogicExpression:
"""A parsed and normalized JSON Logic expression.

A JSON Logic expression can be:

- a single item dictionary, mapping the operator key to another :class:`JSONLogicExpression`,
- a :data:`~jsonlogic.typing.JSONLogicPrimitive`.
The underlying structure of an expression is a single item dictionary,
mapping the operator key to a list of arguments.

All JSON Logic expressions should be instantiated using the :meth:`from_json` constructor::

expr = JSONLogicExpression.from_json(...)
expr = JSONLogicExpression.from_json({"op": ...})
"""

expression: JSONLogicPrimitive | NormalizedExpression
expression: NormalizedExpression

@classmethod
def _parse_impl(cls, json: JSON) -> ExprArgument:
if isinstance(json, dict):
return cls.from_json(json)
if isinstance(json, list):
return [cls._parse_impl(s) for s in json]
return json

@classmethod
def from_json(cls, json: JSON) -> Self: # TODO disallow list? TODO fix type errors
def from_json(cls, json: JSONObject) -> Self:
"""Build a JSON Logic expression from JSON data.

Operator arguments are recursively normalized to a :class:`list`::
Expand All @@ -91,30 +99,34 @@ def from_json(cls, json: JSON) -> Self: # TODO disallow list? TODO fix type err
assert expr.expression == {"var": ["varname"]}
"""
if not isinstance(json, dict):
return cls(expression=json) # type: ignore
raise ValueError("The root node of the expression must be a dict")

operator, op_args = next(iter(json.items()))
if not isinstance(op_args, list):
op_args = [op_args]

sub_expressions = [cls.from_json(op_arg) for op_arg in op_args]
return cls({operator: [cls._parse_impl(arg) for arg in op_args]})

return cls({operator: sub_expressions}) # type: ignore
def _as_op_impl(self, op_arg: ExprArgument, operator_registry: OperatorRegistry) -> OperatorArgument:
if isinstance(op_arg, JSONLogicExpression):
return op_arg.as_operator_tree(operator_registry)
if isinstance(op_arg, list):
return [self._as_op_impl(sub_arg, operator_registry) for sub_arg in op_arg]
return op_arg

def as_operator_tree(self, operator_registry: OperatorRegistry) -> JSONLogicPrimitive | Operator:
def as_operator_tree(self, operator_registry: OperatorRegistry) -> Operator:
"""Return a recursive tree of operators, using the provided registry as a reference.

Args:
operator_registry: The registry to use to resolve operator IDs.

Returns:
The current expression if it is a :data:`~jsonlogic.typing.JSONLogicPrimitive`
or an :class:`Operator` instance.
An :class:`Operator` instance.
"""
if not isinstance(self.expression, dict):
return self.expression

op_id, op_args = next(iter(self.expression.items()))
OperatorCls = operator_registry.get(op_id)

return OperatorCls.from_expression(op_id, [op_arg.as_operator_tree(operator_registry) for op_arg in op_args])
return OperatorCls.from_expression(op_id, [self._as_op_impl(op_arg, operator_registry) for op_arg in op_args])
2 changes: 2 additions & 0 deletions src/jsonlogic/evaluation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ def get_value(obj: OperatorArgument, context: EvaluationContext) -> Any:
"""
if isinstance(obj, Operator):
return obj.evaluate(context)
if isinstance(obj, list):
return [get_value(sub_obj, context) for sub_obj in obj]
return _cast_value(obj, context.settings.literal_casts)
4 changes: 3 additions & 1 deletion src/jsonlogic/typechecking/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from jsonlogic.core import Operator
from jsonlogic.json_schema import from_value
from jsonlogic.json_schema.types import JSONSchemaType
from jsonlogic.json_schema.types import ArrayType, JSONSchemaType, UnionType
from jsonlogic.typing import OperatorArgument

from .diagnostics import Diagnostic
Expand Down Expand Up @@ -44,4 +44,6 @@ def get_type(obj: OperatorArgument, context: TypecheckContext) -> JSONSchemaType
"""
if isinstance(obj, Operator):
return obj.typecheck(context)
if isinstance(obj, list):
return ArrayType(UnionType(*(get_type(sub_obj, context) for sub_obj in obj)))
return from_value(obj, context.settings.literal_casts)
18 changes: 10 additions & 8 deletions src/jsonlogic/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,31 @@
JSONArray: TypeAlias = "list[JSON]"
JSON: TypeAlias = "JSONPrimitive | JSONArray | JSONObject"

JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONPrimitive]"
"""A JSON Logic primitive is defined as either a JSON primitive or a list of JSON primitives.
JSONLogicPrimitive: TypeAlias = "JSONPrimitive | list[JSONLogicPrimitive]"
"""A JSON Logic primitive is recursively defined as either a JSON primitive or a list of JSON Logic primitives.

Such primitives are only considered when dealing with operator arguments:

.. code-block:: javascript
.. code-block:: json

{
"op": [
"a string", // A valid primitive (in this case a JSON primitive)
["a list"] // A list of JSON primitives
["a list"], // A list of JSON primitives
[1, [2, 3]]
]
}
"""

OperatorArgument: TypeAlias = "JSONLogicPrimitive | Operator"
"""A valid operator argument, either a JSON Logic primitive or an operator.
OperatorArgument: TypeAlias = "Operator | JSONLogicPrimitive | list[OperatorArgument]"
"""An operator argument is recursively defined a JSON Logic primitive, an operator or a list of operator arguments.

.. code-block:: javascript
.. code-block:: json

{
"op": [
{"nested_op": ...}, // A nested operator
{"nested_op": "..."}, // A nested operator
[1, {"other_op": "..."}],
["a list"] // A JSON Logic primitive
]
}
Expand Down
20 changes: 18 additions & 2 deletions tests/operators/test_evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ def test_map() -> 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():
def test_map_op_in_values() -> None:
op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]})

rv = evaluate(
Expand All @@ -198,6 +197,23 @@ def test_map_op_in_values():
assert rv == [True, False]


def test_nested_map() -> None:
op = as_op(
{
"map": [
[[1, 2], [3, {"var": "/my_number"}]],
{"var": ""},
],
}
)

rv = evaluate(
op, data={"my_number": 4}, data_schema={"type": "object", "properties": {"my_number": {"type": "integer"}}}
)

assert rv == [[1, 2], [3, 4]]


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.
Expand Down
20 changes: 16 additions & 4 deletions tests/operators/test_typecheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
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
Expand Down Expand Up @@ -232,8 +230,7 @@ def test_map() -> None:
assert diagnostics == []


@pytest.mark.xfail(reason="Arrays are currently considered as JSON Logic primitives.")
def test_map_op_in_values():
def test_map_op_in_values() -> None:
op = as_op({"map": [["2000-01-01", {"var": "/my_date"}], {">": [{"var": ""}, "1970-01-01"]}]})

rt, _ = typecheck(
Expand All @@ -245,6 +242,21 @@ def test_map_op_in_values():
assert rt == ArrayType(BooleanType())


def test_nested_map() -> None:
op = as_op(
{
"map": [
[[1, 2], [3, {"var": "/my_number"}]],
{"var": ""},
],
}
)

rt, _ = typecheck(op, data_schema={"type": "object", "properties": {"my_number": {"type": "integer"}}})

assert rt == ArrayType(ArrayType(IntegerType()))


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.
Expand Down
9 changes: 9 additions & 0 deletions tests/test_json_logic_expression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from jsonlogic import JSONLogicExpression


def test_from_json() -> None:
expr = JSONLogicExpression.from_json({"op": [1, {"op": 2}, [3, [4, 5]], [6, [{"op": 7}]]]})

assert expr == JSONLogicExpression(
{"op": [1, JSONLogicExpression({"op": [2]}), [3, [4, 5]], [6, [JSONLogicExpression({"op": [7]})]]]}
)