python-jsonlogic 0.0.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jsonlogic/__init__.py +3 -0
- jsonlogic/_compat.py +7 -4
- jsonlogic/core.py +72 -16
- jsonlogic/evaluation/__init__.py +5 -0
- jsonlogic/evaluation/evaluation_context.py +74 -0
- jsonlogic/evaluation/evaluation_settings.py +105 -0
- jsonlogic/evaluation/utils.py +63 -0
- jsonlogic/json_schema/__init__.py +152 -40
- jsonlogic/json_schema/types.py +294 -53
- jsonlogic/operators/__init__.py +39 -7
- jsonlogic/operators/operators.py +306 -66
- jsonlogic/registry.py +58 -13
- jsonlogic/resolving.py +201 -0
- jsonlogic/typechecking/__init__.py +15 -0
- jsonlogic/typechecking/diagnostics.py +37 -0
- jsonlogic/typechecking/typecheck_context.py +69 -0
- jsonlogic/typechecking/typecheck_settings.py +213 -0
- jsonlogic/typechecking/utils.py +49 -0
- jsonlogic/typing.py +37 -6
- jsonlogic/utils.py +41 -7
- python_jsonlogic-0.2.0.dist-info/METADATA +120 -0
- python_jsonlogic-0.2.0.dist-info/RECORD +25 -0
- {python_jsonlogic-0.0.1.dist-info → python_jsonlogic-0.2.0.dist-info}/WHEEL +1 -2
- jsonlogic/json_schema/resolvers.py +0 -51
- jsonlogic/operators/base.py +0 -0
- jsonlogic/operators/typechecking.py +0 -16
- python_jsonlogic-0.0.1.dist-info/METADATA +0 -66
- python_jsonlogic-0.0.1.dist-info/RECORD +0 -19
- python_jsonlogic-0.0.1.dist-info/top_level.txt +0 -1
- {python_jsonlogic-0.0.1.dist-info → python_jsonlogic-0.2.0.dist-info/licenses}/LICENSE +0 -0
jsonlogic/__init__.py
CHANGED
jsonlogic/_compat.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
|
|
3
|
-
if sys.version_info >= (3,
|
|
4
|
-
from typing import Self, TypeAlias,
|
|
3
|
+
if sys.version_info >= (3, 13):
|
|
4
|
+
from typing import Self, TypeAlias, TypeIs, TypeVarTuple, Unpack
|
|
5
5
|
else:
|
|
6
|
-
from
|
|
6
|
+
from typing import TypeAlias
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
from typing_extensions import Self, TypeIs, TypeVarTuple, Unpack
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ("Self", "TypeAlias", "TypeIs", "TypeVarTuple", "Unpack")
|
jsonlogic/core.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""Base structures of the library. The two classes defined, :class:`Operator` and :class:`JSONLogicExpression`,
|
|
2
|
+
can be extended to provide extra functionality.
|
|
3
|
+
"""
|
|
4
|
+
|
|
1
5
|
from __future__ import annotations
|
|
2
6
|
|
|
3
7
|
from abc import ABC, abstractmethod
|
|
@@ -6,13 +10,12 @@ from typing import TYPE_CHECKING, Any
|
|
|
6
10
|
|
|
7
11
|
from ._compat import Self, TypeAlias
|
|
8
12
|
from .json_schema.types import AnyType, JSONSchemaType
|
|
9
|
-
from .typing import JSON, JSONLogicPrimitive, OperatorArgument
|
|
13
|
+
from .typing import JSON, JSONLogicPrimitive, JSONObject, OperatorArgument
|
|
10
14
|
|
|
11
15
|
if TYPE_CHECKING:
|
|
12
|
-
|
|
13
|
-
from typing import TypeAlias
|
|
14
|
-
|
|
16
|
+
from .evaluation import EvaluationContext
|
|
15
17
|
from .registry import OperatorRegistry
|
|
18
|
+
from .typechecking import TypecheckContext
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
@dataclass
|
|
@@ -31,43 +34,96 @@ class Operator(ABC):
|
|
|
31
34
|
@classmethod
|
|
32
35
|
@abstractmethod
|
|
33
36
|
def from_expression(cls, operator: str, arguments: list[OperatorArgument]) -> Self:
|
|
34
|
-
"""Return an instance of the operator from the list of provided arguments.
|
|
37
|
+
"""Return an instance of the operator from the list of provided arguments.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
operator: The ID of the operator, as provided by the :class:`~jsonlogic.registry.OperatorRegistry`.
|
|
41
|
+
arguments: The list of the arguments for this operator. Subclasses are responsible
|
|
42
|
+
for checking the correct number of arguments and optionally the types.
|
|
43
|
+
"""
|
|
35
44
|
|
|
36
45
|
@abstractmethod
|
|
37
|
-
def
|
|
38
|
-
|
|
46
|
+
def evaluate(self, context: EvaluationContext) -> Any:
|
|
47
|
+
"""Evaluate the operator with the provided data."""
|
|
39
48
|
|
|
40
|
-
def typecheck(self,
|
|
49
|
+
def typecheck(self, context: TypecheckContext) -> JSONSchemaType:
|
|
41
50
|
"""Typecheck the operator (and all children) given the data schema."""
|
|
42
51
|
|
|
43
52
|
return AnyType()
|
|
44
53
|
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
class JSONLogicSyntaxError(Exception):
|
|
56
|
+
"""A syntax error when building an operator tree from a :class:`JSONLogicExpression`."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, /) -> None:
|
|
59
|
+
self.message = message
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ExprArgument: TypeAlias = "JSONLogicPrimitive | JSONLogicExpression | list[ExprArgument]"
|
|
63
|
+
|
|
64
|
+
NormalizedExpression: TypeAlias = "dict[str, list[ExprArgument]]"
|
|
47
65
|
|
|
48
66
|
|
|
49
67
|
@dataclass
|
|
50
68
|
class JSONLogicExpression:
|
|
51
|
-
|
|
69
|
+
"""A parsed and normalized JSON Logic expression.
|
|
70
|
+
|
|
71
|
+
The underlying structure of an expression is a single item dictionary,
|
|
72
|
+
mapping the operator key to a list of arguments.
|
|
73
|
+
|
|
74
|
+
All JSON Logic expressions should be instantiated using the :meth:`from_json` constructor::
|
|
75
|
+
|
|
76
|
+
expr = JSONLogicExpression.from_json({"op": ...})
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
expression: NormalizedExpression
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def _parse_impl(cls, json: JSON) -> ExprArgument:
|
|
83
|
+
if isinstance(json, dict):
|
|
84
|
+
return cls.from_json(json)
|
|
85
|
+
if isinstance(json, list):
|
|
86
|
+
return [cls._parse_impl(s) for s in json]
|
|
87
|
+
return json
|
|
52
88
|
|
|
53
89
|
@classmethod
|
|
54
|
-
def from_json(cls, json:
|
|
90
|
+
def from_json(cls, json: JSONObject) -> Self:
|
|
91
|
+
"""Build a JSON Logic expression from JSON data.
|
|
92
|
+
|
|
93
|
+
Operator arguments are recursively normalized to a :class:`list`::
|
|
94
|
+
|
|
95
|
+
expr = JSONLogicExpression.from_json({"var": "varname"})
|
|
96
|
+
assert expr.expression == {"var": ["varname"]}
|
|
97
|
+
"""
|
|
55
98
|
if not isinstance(json, dict):
|
|
56
|
-
|
|
99
|
+
raise ValueError("The root node of the expression must be a dict")
|
|
57
100
|
|
|
58
101
|
operator, op_args = next(iter(json.items()))
|
|
59
102
|
if not isinstance(op_args, list):
|
|
60
103
|
op_args = [op_args]
|
|
61
104
|
|
|
62
|
-
|
|
105
|
+
return cls({operator: [cls._parse_impl(arg) for arg in op_args]})
|
|
106
|
+
|
|
107
|
+
def _as_op_impl(self, op_arg: ExprArgument, operator_registry: OperatorRegistry) -> OperatorArgument:
|
|
108
|
+
if isinstance(op_arg, JSONLogicExpression):
|
|
109
|
+
return op_arg.as_operator_tree(operator_registry)
|
|
110
|
+
if isinstance(op_arg, list):
|
|
111
|
+
return [self._as_op_impl(sub_arg, operator_registry) for sub_arg in op_arg]
|
|
112
|
+
return op_arg
|
|
113
|
+
|
|
114
|
+
def as_operator_tree(self, operator_registry: OperatorRegistry) -> Operator:
|
|
115
|
+
"""Return a recursive tree of operators, using the provided registry as a reference.
|
|
63
116
|
|
|
64
|
-
|
|
117
|
+
Args:
|
|
118
|
+
operator_registry: The registry to use to resolve operator IDs.
|
|
65
119
|
|
|
66
|
-
|
|
120
|
+
Returns:
|
|
121
|
+
An :class:`Operator` instance.
|
|
122
|
+
"""
|
|
67
123
|
if not isinstance(self.expression, dict):
|
|
68
124
|
return self.expression
|
|
69
125
|
|
|
70
126
|
op_id, op_args = next(iter(self.expression.items()))
|
|
71
127
|
OperatorCls = operator_registry.get(op_id)
|
|
72
128
|
|
|
73
|
-
return OperatorCls.from_expression(op_id, [
|
|
129
|
+
return OperatorCls.from_expression(op_id, [self._as_op_impl(op_arg, operator_registry) for op_arg in op_args])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, overload
|
|
4
|
+
|
|
5
|
+
from jsonlogic.json_schema import cast_from_schema
|
|
6
|
+
from jsonlogic.resolving import resolve_data, resolve_json_schema
|
|
7
|
+
from jsonlogic.typing import JSON
|
|
8
|
+
from jsonlogic.utils import DataStack
|
|
9
|
+
|
|
10
|
+
from .evaluation_settings import EvaluationSettings, EvaluationSettingsDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EvaluationContext:
|
|
14
|
+
"""A context object used when evaluating operators.
|
|
15
|
+
|
|
16
|
+
When evaluating an :class:`~jsonlogic.core.Operator`, an instance of this
|
|
17
|
+
class should be used.
|
|
18
|
+
|
|
19
|
+
.. code-block:: pycon
|
|
20
|
+
|
|
21
|
+
>>> expr = JSONLogicExpression.from_json({"var": "/a_date"})
|
|
22
|
+
>>> root_op = expr.as_operator_tree(operator_registry)
|
|
23
|
+
>>> context = EvaluationContext(
|
|
24
|
+
... data={"a_date": "1970-01-01"},
|
|
25
|
+
... data_schema={
|
|
26
|
+
... "type": "object",
|
|
27
|
+
... "properties": {
|
|
28
|
+
... "a_date": {"type": "string", "format": "date"},
|
|
29
|
+
... },
|
|
30
|
+
... },
|
|
31
|
+
... )
|
|
32
|
+
>>> root_op.evaluate(context)
|
|
33
|
+
datetime.date(1970, 1, 1)
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
root_data: The root data available during evaluation.
|
|
37
|
+
data_schema: The matching JSON Schema describing the root data. This should be the same JSON Schema
|
|
38
|
+
used during typechecking (see :paramref:`~jsonlogic.typechecking.TypecheckContext.root_data_schema`).
|
|
39
|
+
settings: Settings to be used when evaluating an :class:`~jsonlogic.core.Operator`.
|
|
40
|
+
See :class:`EvaluationSettings` for the available settings and default values.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, root_data: JSON, data_schema: dict[str, Any] | None = None, settings: EvaluationSettingsDict | None = None
|
|
45
|
+
) -> None:
|
|
46
|
+
self.data_stack = DataStack((root_data, data_schema))
|
|
47
|
+
self.settings = EvaluationSettings.from_dict(settings) if settings is not None else EvaluationSettings()
|
|
48
|
+
|
|
49
|
+
@overload
|
|
50
|
+
def resolve_variable(self, reference: str, *, bare: Literal[True]) -> JSON: ...
|
|
51
|
+
|
|
52
|
+
@overload
|
|
53
|
+
def resolve_variable(self, reference: str, *, bare: Literal[False] = ...) -> Any: ...
|
|
54
|
+
|
|
55
|
+
def resolve_variable(self, reference: str, *, bare: bool = False) -> JSON | Any:
|
|
56
|
+
"""Resolve a variable given the string reference pointing to it.
|
|
57
|
+
|
|
58
|
+
The format of the reference should match the reference parser defined
|
|
59
|
+
in the :class:`EvaluationSettings`.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
reference: The string reference of the variable.
|
|
63
|
+
bare: Whether the resolved value should be casted to a specific Python
|
|
64
|
+
type according to the matching JSON Schema. Note that this will only
|
|
65
|
+
be possible if a :paramref:`~EvaluationContext.data_schema` was provided.
|
|
66
|
+
"""
|
|
67
|
+
parsed_reference, scope = self.settings.reference_parser(reference)
|
|
68
|
+
root_data, root_schema = self.data_stack.get(scope)
|
|
69
|
+
bare_value = resolve_data(parsed_reference, root_data)
|
|
70
|
+
if bare or root_schema is None:
|
|
71
|
+
return bare_value
|
|
72
|
+
|
|
73
|
+
schema = resolve_json_schema(parsed_reference, root_schema)
|
|
74
|
+
return cast_from_schema(bare_value, schema, self.settings.variable_casts)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any, TypedDict
|
|
7
|
+
|
|
8
|
+
from jsonlogic._compat import Self
|
|
9
|
+
from jsonlogic.resolving import PointerReferenceParser, ReferenceParser
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _d_variable_casts() -> dict[str, Callable[[str], Any]]:
|
|
13
|
+
return {
|
|
14
|
+
"date": date.fromisoformat,
|
|
15
|
+
"date-time": datetime.fromisoformat,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class EvaluationSettings:
|
|
21
|
+
"""Settings used when evaluating an :class:`~jsonlogic.core.Operator`."""
|
|
22
|
+
|
|
23
|
+
reference_parser: ReferenceParser = field(default_factory=PointerReferenceParser)
|
|
24
|
+
"""A reference parser instance to use when resolving variables.
|
|
25
|
+
|
|
26
|
+
Default: :class:`~jsonlogic.resolving.PointerReferenceParser`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
variable_casts: dict[str, Callable[[str], Any]] = field(default_factory=_d_variable_casts)
|
|
30
|
+
"""A mapping between `JSON Schema formats`_ and their corresponding conversion callable.
|
|
31
|
+
|
|
32
|
+
When an operator reads variables from the provided data (such as the ``"var"`` operator),
|
|
33
|
+
such variables of type :class:`str` may be converted to a specific Python type if
|
|
34
|
+
the corresponding JSON Schema of the data was provided during evaluation.
|
|
35
|
+
|
|
36
|
+
This setting is analogous to the :attr:`~jsonlogic.typechecking.TypecheckSettings.variable_casts`
|
|
37
|
+
configuration of the :class:`~jsonlogic.typechecking.TypecheckSettings` class.
|
|
38
|
+
|
|
39
|
+
Default: :python:`{"date": date.fromisoformat, "date-time": datetime.fromisoformat}`.
|
|
40
|
+
|
|
41
|
+
.. _JSON Schema formats: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.2
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
literal_casts: list[Callable[[str], Any]] = field(default_factory=list)
|
|
45
|
+
"""A list of conversion callables to try when encountering a literal string value during evaluation.
|
|
46
|
+
|
|
47
|
+
When a literal string value is encountered in a JSON Logic expression, it might be
|
|
48
|
+
beneficial to convert it to a specific Python type.
|
|
49
|
+
|
|
50
|
+
This setting is analogous to the :attr:`~jsonlogic.typechecking.TypecheckSettings.literal_casts`
|
|
51
|
+
configuration of the :class:`~jsonlogic.typechecking.TypecheckSettings` class.
|
|
52
|
+
|
|
53
|
+
Default: :python:`[]` (no cast).
|
|
54
|
+
|
|
55
|
+
.. warning::
|
|
56
|
+
|
|
57
|
+
The order in which the conversion callables are defined matters. Each
|
|
58
|
+
callable will be applied one after the other until no exception is raised.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, dct: EvaluationSettingsDict, /) -> Self:
|
|
63
|
+
return cls(**dct)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EvaluationSettingsDict(TypedDict, total=False):
|
|
67
|
+
"""Settings used when evaluating an :class:`~jsonlogic.core.Operator`."""
|
|
68
|
+
|
|
69
|
+
reference_parser: ReferenceParser
|
|
70
|
+
"""A reference parser instance to use when resolving variables.
|
|
71
|
+
|
|
72
|
+
Default: :class:`~jsonlogic.resolving.PointerReferenceParser`.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
variable_casts: dict[str, Callable[[str], Any]]
|
|
76
|
+
"""A mapping between `JSON Schema formats`_ and their corresponding conversion callable.
|
|
77
|
+
|
|
78
|
+
When an operator reads variables from the provided data (such as the ``"var"`` operator),
|
|
79
|
+
such variables of type :class:`str` may be converted to a specific Python type if
|
|
80
|
+
the corresponding JSON Schema of the data was provided during evaluation.
|
|
81
|
+
|
|
82
|
+
This setting is analogous to the :attr:`~jsonlogic.typechecking.TypecheckSettings.variable_casts`
|
|
83
|
+
configuration of the :class:`~jsonlogic.typechecking.TypecheckSettings` class.
|
|
84
|
+
|
|
85
|
+
Default: :python:`{"date": date.fromisoformat, "date-time": datetime.fromisoformat}`.
|
|
86
|
+
|
|
87
|
+
.. _JSON Schema formats: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.2
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
literal_casts: list[Callable[[str], Any]]
|
|
91
|
+
"""A list of conversion callables to try when encountering a literal string value during evaluation.
|
|
92
|
+
|
|
93
|
+
When a literal string value is encountered in a JSON Logic expression, it might be
|
|
94
|
+
beneficial to convert it to a specific Python type.
|
|
95
|
+
|
|
96
|
+
This setting is analogous to the :attr:`~jsonlogic.typechecking.TypecheckSettings.literal_casts`
|
|
97
|
+
configuration of the :class:`~jsonlogic.typechecking.TypecheckSettings` class.
|
|
98
|
+
|
|
99
|
+
Default: :python:`[]` (no cast).
|
|
100
|
+
|
|
101
|
+
.. warning::
|
|
102
|
+
|
|
103
|
+
The order in which the conversion callables are defined matters. Each
|
|
104
|
+
callable will be applied one after the other until no exception is raised.
|
|
105
|
+
"""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jsonlogic.core import Operator
|
|
7
|
+
from jsonlogic.typing import JSON, JSONLogicPrimitive, OperatorArgument
|
|
8
|
+
|
|
9
|
+
from .evaluation_context import EvaluationContext
|
|
10
|
+
from .evaluation_settings import EvaluationSettingsDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def evaluate(
|
|
14
|
+
operator: Operator, data: JSON, data_schema: dict[str, Any] | None, settings: EvaluationSettingsDict | None = None
|
|
15
|
+
) -> Any:
|
|
16
|
+
"""Helper function to evaluate an :class:`~jsonlogic.core.Operator`.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
operator: The operator to evaluate.
|
|
20
|
+
data: The root data available during evaluation.
|
|
21
|
+
data_schema: The matching JSON Schema describing the root data. This should be the same JSON Schema
|
|
22
|
+
used during typechecking (see :paramref:`~jsonlogic.typechecking.TypecheckContext.root_data_schema`).
|
|
23
|
+
settings: Settings to be used when evaluating an :class:`~jsonlogic.core.Operator`.
|
|
24
|
+
See :class:`EvaluationSettings` for the available settings and default values.
|
|
25
|
+
Returns:
|
|
26
|
+
The evaluated value.
|
|
27
|
+
"""
|
|
28
|
+
context = EvaluationContext(data, data_schema, settings)
|
|
29
|
+
return operator.evaluate(context)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Function analogous to :func:`jsonlogic.json_schema.from_value`
|
|
33
|
+
def _cast_value(value: JSONLogicPrimitive, literal_casts: list[Callable[[str], Any]]) -> Any:
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
for func in literal_casts:
|
|
36
|
+
try:
|
|
37
|
+
casted_value = func(value)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
return casted_value
|
|
42
|
+
|
|
43
|
+
if not isinstance(value, list):
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
return [_cast_value(subval, literal_casts) for subval in value]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_value(obj: OperatorArgument, context: EvaluationContext) -> Any:
|
|
50
|
+
"""Get the value of an operator argument.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
obj: the object to evaluate. If this is an :class:`~jsonlogic.core.Operator`,
|
|
54
|
+
it is evaluated and the value is returned. Otherwise, it must be a
|
|
55
|
+
:data:`~jsonlogic.typing.JSONLogicPrimitive`, and the type is inferred from
|
|
56
|
+
the actual value according to the :attr:`~TypecheckSettings.literal_casts` setting.
|
|
57
|
+
context: The typecheck context.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(obj, Operator):
|
|
60
|
+
return obj.evaluate(context)
|
|
61
|
+
if isinstance(obj, list):
|
|
62
|
+
return [get_value(sub_obj, context) for sub_obj in obj]
|
|
63
|
+
return _cast_value(obj, context.settings.literal_casts)
|
|
@@ -1,77 +1,189 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
2
4
|
from types import NoneType
|
|
3
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, cast
|
|
4
6
|
|
|
5
|
-
from jsonlogic.
|
|
7
|
+
from jsonlogic._compat import TypeIs
|
|
8
|
+
from jsonlogic.typing import JSON, JSONLogicPrimitive
|
|
6
9
|
|
|
7
10
|
from .types import (
|
|
8
11
|
AnyType,
|
|
12
|
+
ArrayType,
|
|
9
13
|
BooleanType,
|
|
10
|
-
DatetimeType,
|
|
11
|
-
DateType,
|
|
12
14
|
IntegerType,
|
|
13
15
|
JSONSchemaType,
|
|
14
16
|
NullType,
|
|
15
17
|
NumberType,
|
|
16
18
|
StringType,
|
|
19
|
+
TupleType,
|
|
20
|
+
UnionType,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
"as_json_schema",
|
|
25
|
+
"cast_from_schema",
|
|
26
|
+
"from_json_schema",
|
|
27
|
+
"from_value",
|
|
17
28
|
)
|
|
18
29
|
|
|
19
|
-
_VALUE_TYPE_MAP: dict[type[Any], JSONSchemaType] = {
|
|
20
|
-
bool: BooleanType
|
|
21
|
-
float: NumberType
|
|
22
|
-
int: IntegerType
|
|
23
|
-
NoneType: NullType
|
|
30
|
+
_VALUE_TYPE_MAP: dict[type[Any], type[JSONSchemaType]] = {
|
|
31
|
+
bool: BooleanType,
|
|
32
|
+
float: NumberType,
|
|
33
|
+
int: IntegerType,
|
|
34
|
+
NoneType: NullType,
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
_PRIMITIVES_TYPE_MAP: dict[str, type[JSONSchemaType]] = {
|
|
38
|
+
"boolean": BooleanType,
|
|
39
|
+
"number": NumberType,
|
|
40
|
+
"integer": IntegerType,
|
|
41
|
+
"null": NullType,
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
_R_PRIMITIVES_TYPE_MAP: dict[type[JSONSchemaType], str] = {
|
|
45
|
+
**{v: k for k, v in _PRIMITIVES_TYPE_MAP.items()},
|
|
46
|
+
StringType: "string",
|
|
47
|
+
}
|
|
31
48
|
|
|
32
|
-
def from_value(value: JSONLogicPrimitive) -> JSONSchemaType:
|
|
33
|
-
if type(value) in _VALUE_TYPE_MAP:
|
|
34
|
-
return _VALUE_TYPE_MAP[type(value)]
|
|
35
49
|
|
|
50
|
+
# Defined purely for type checking purposes:
|
|
51
|
+
def _is_primitive_not_str(value: object) -> TypeIs[bool | float | None]:
|
|
52
|
+
return type(value) in _VALUE_TYPE_MAP
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_value(
|
|
56
|
+
value: JSONLogicPrimitive, literal_casts: dict[Callable[[str], Any], type[JSONSchemaType]]
|
|
57
|
+
) -> JSONSchemaType:
|
|
36
58
|
if isinstance(value, str):
|
|
37
|
-
for func, js_type in
|
|
59
|
+
for func, js_type in literal_casts.items():
|
|
38
60
|
try:
|
|
39
61
|
func(value)
|
|
40
62
|
except Exception:
|
|
41
63
|
pass
|
|
42
64
|
else:
|
|
43
|
-
return js_type
|
|
65
|
+
return js_type()
|
|
44
66
|
|
|
45
67
|
return StringType()
|
|
46
68
|
|
|
47
|
-
|
|
69
|
+
if _is_primitive_not_str(value):
|
|
70
|
+
return _VALUE_TYPE_MAP[type(value)]()
|
|
48
71
|
|
|
72
|
+
# A design decision had to be made: we infer arrays as arrays and not as tuples,
|
|
73
|
+
# even if types are different.
|
|
74
|
+
if value:
|
|
75
|
+
return ArrayType(UnionType(*(from_value(val, literal_casts) for val in value)))
|
|
49
76
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"number": NumberType(),
|
|
53
|
-
"integer": IntegerType(),
|
|
54
|
-
"null": NullType(),
|
|
55
|
-
}
|
|
77
|
+
# Empty array: can't infer type
|
|
78
|
+
return ArrayType(AnyType())
|
|
56
79
|
|
|
57
|
-
_FORMAT_MAP: dict[str, JSONSchemaType] = {
|
|
58
|
-
"date-time": DatetimeType(),
|
|
59
|
-
"date": DateType(),
|
|
60
|
-
}
|
|
61
80
|
|
|
81
|
+
def from_json_schema(json_schema: dict[str, Any], variable_casts: dict[str, type[JSONSchemaType]]) -> JSONSchemaType:
|
|
82
|
+
js_types = cast("list[str] | str | None", json_schema.get("type"))
|
|
83
|
+
if js_types is None:
|
|
84
|
+
return AnyType()
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
js_type = cast(str | None, json_schema.get("type"))
|
|
66
|
-
if js_type in _TYPE_MAP:
|
|
67
|
-
return _TYPE_MAP[js_type]
|
|
86
|
+
if not isinstance(js_types, list):
|
|
87
|
+
js_types = [js_types]
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
def _from_type(js_type: str, json_schema: dict[str, Any]) -> JSONSchemaType:
|
|
90
|
+
if js_type in _PRIMITIVES_TYPE_MAP:
|
|
91
|
+
return _PRIMITIVES_TYPE_MAP[js_type]()
|
|
71
92
|
|
|
72
|
-
if
|
|
73
|
-
|
|
93
|
+
if js_type == "string":
|
|
94
|
+
format = cast("str | None", json_schema.get("format"))
|
|
95
|
+
if format in variable_casts:
|
|
96
|
+
return variable_casts[format]()
|
|
74
97
|
|
|
75
|
-
|
|
98
|
+
return StringType()
|
|
99
|
+
|
|
100
|
+
if js_type == "array":
|
|
101
|
+
items_type = cast("dict[str, Any] | None", json_schema.get("items"))
|
|
102
|
+
if items_type is not None:
|
|
103
|
+
return ArrayType(from_json_schema(items_type, variable_casts))
|
|
104
|
+
|
|
105
|
+
prefix_items = cast("list[dict[str, Any]] | None", json_schema.get("prefixItems"))
|
|
106
|
+
min_items = cast("int | None", json_schema.get("minItems"))
|
|
107
|
+
max_items = cast("int | None", json_schema.get("maxItems"))
|
|
108
|
+
if prefix_items is not None and min_items is not None and min_items == max_items:
|
|
109
|
+
return TupleType(tuple(from_json_schema(item, variable_casts) for item in prefix_items))
|
|
110
|
+
|
|
111
|
+
return ArrayType(AnyType())
|
|
112
|
+
|
|
113
|
+
return AnyType()
|
|
114
|
+
|
|
115
|
+
return UnionType(*(_from_type(js_type, json_schema) for js_type in js_types))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def as_json_schema(type: JSONSchemaType, variable_casts: dict[str, type[JSONSchemaType]]) -> dict[str, Any]:
|
|
119
|
+
type_class = type.__class__
|
|
120
|
+
if type_class in _R_PRIMITIVES_TYPE_MAP:
|
|
121
|
+
return {"type": _R_PRIMITIVES_TYPE_MAP[type_class]}
|
|
122
|
+
|
|
123
|
+
if isinstance(type, AnyType):
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
if isinstance(type, UnionType):
|
|
127
|
+
sub_schemas = [as_json_schema(subtype, variable_casts) for subtype in type.types]
|
|
128
|
+
types = [
|
|
129
|
+
sub_schema.pop("type") # UnionTypes can't have any, so `"type"` is guaranteed to be present
|
|
130
|
+
for sub_schema in sub_schemas
|
|
131
|
+
]
|
|
132
|
+
schema = {
|
|
133
|
+
"type": types[0] if len(types) == 1 else types,
|
|
134
|
+
}
|
|
135
|
+
for sub_schema in sub_schemas:
|
|
136
|
+
schema.update(sub_schema)
|
|
137
|
+
return schema
|
|
138
|
+
|
|
139
|
+
if isinstance(type, ArrayType):
|
|
140
|
+
items_type = as_json_schema(type.elements_type, variable_casts)
|
|
141
|
+
if items_type:
|
|
142
|
+
return {"type": "array", "items": items_type}
|
|
143
|
+
return {"type": "array"}
|
|
144
|
+
|
|
145
|
+
if isinstance(type, TupleType):
|
|
146
|
+
return {
|
|
147
|
+
"type": "array",
|
|
148
|
+
"minItems": len(type.tuple_types),
|
|
149
|
+
"maxItems": len(type.tuple_types),
|
|
150
|
+
"prefixItems": [as_json_schema(subtype, variable_casts) for subtype in type.tuple_types],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
r_variable_casts = {v: k for k, v in variable_casts.items()}
|
|
154
|
+
|
|
155
|
+
if type_class in r_variable_casts:
|
|
156
|
+
return {"type": "string", "format": r_variable_casts[type_class]}
|
|
157
|
+
|
|
158
|
+
raise RuntimeError(f"Unable to determine JSON Schema for type {type}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cast_from_schema(value: JSON, json_schema: dict[str, Any], variable_casts: dict[str, Callable[[str], Any]]) -> Any:
|
|
162
|
+
js_types = cast("list[str] | str | None", json_schema.get("type"))
|
|
163
|
+
if js_types is None:
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
if not isinstance(js_types, list):
|
|
167
|
+
js_types = [js_types]
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
isinstance(value, str)
|
|
171
|
+
and "string" in js_types
|
|
172
|
+
and (cast_func := variable_casts.get(json_schema.get("format"))) # type: ignore
|
|
173
|
+
): # fmt: skip
|
|
174
|
+
return cast_func(value)
|
|
76
175
|
|
|
77
|
-
|
|
176
|
+
if isinstance(value, list) and "array" in js_types:
|
|
177
|
+
items_type = cast("dict[str, Any] | None", json_schema.get("items"))
|
|
178
|
+
if items_type is not None:
|
|
179
|
+
return [cast_from_schema(val, items_type, variable_casts) for val in value]
|
|
180
|
+
|
|
181
|
+
prefix_items = cast("list[dict[str, Any]] | None", json_schema.get("prefixItems"))
|
|
182
|
+
if prefix_items is not None: # TODO check for min/maxItems?
|
|
183
|
+
# TODO return a tuple instead? Needs decision
|
|
184
|
+
return [
|
|
185
|
+
cast_from_schema(val, item_type, variable_casts)
|
|
186
|
+
for val, item_type in zip(value, prefix_items, strict=True)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
return value
|