rule-engine 5.0.2__tar.gz → 5.2.0__tar.gz
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.
- {rule_engine-5.0.2 → rule_engine-5.2.0}/PKG-INFO +2 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/README.rst +1 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/__init__.py +5 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/base.py +2 -2
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/arithmetic.py +11 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/base.py +1 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/comparison.py +0 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/access.py +2 -2
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/resolution.py +136 -7
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/literal.py +2 -2
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/builtins.py +8 -2
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/__init__.py +1 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/_attribute_resolver.py +44 -17
- rule_engine-5.2.0/lib/rule_engine/engine/chain.py +289 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/context.py +93 -8
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/errors.py +71 -4
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/__init__.py +19 -0
- rule_engine-5.2.0/lib/rule_engine/parser/__main__.py +38 -0
- rule_engine-5.2.0/lib/rule_engine/parser/_tables.py +241 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/base.py +26 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/utilities.py +33 -12
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/_object.py +13 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/coercion.py +93 -7
- rule_engine-5.2.0/lib/rule_engine/types/dataclass.py +290 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/datatype.py +42 -4
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/definitions.py +115 -13
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/PKG-INFO +2 -1
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/SOURCES.txt +4 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/pyproject.toml +2 -1
- rule_engine-5.2.0/setup.py +53 -0
- rule_engine-5.0.2/lib/rule_engine/types/dataclass.py +0 -149
- {rule_engine-5.0.2 → rule_engine-5.2.0}/LICENSE +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/LICENSE +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/lex.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/yacc.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/control.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/debug_ast.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/debug_repl.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/rule.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/suggestions.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/sqlalchemy.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/dependency_links.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/requires.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/top_level.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rule-engine
|
|
3
|
-
Version: 5.0
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects.
|
|
5
5
|
Author-email: Spencer McIntyre <zeroSteiner@gmail.com>
|
|
6
6
|
Maintainer-email: Spencer McIntyre <zeroSteiner@gmail.com>
|
|
@@ -45,6 +45,7 @@ Python with some inspiration from Ruby. Some features of this language includes:
|
|
|
45
45
|
- Compound datatypes (equivalents for Python dict, list and set types)
|
|
46
46
|
- Data attributes
|
|
47
47
|
- Thread safety
|
|
48
|
+
- Rule chains that return the value of the first matching rule
|
|
48
49
|
|
|
49
50
|
Example Usage
|
|
50
51
|
-------------
|
|
@@ -22,6 +22,7 @@ Python with some inspiration from Ruby. Some features of this language includes:
|
|
|
22
22
|
- Compound datatypes (equivalents for Python dict, list and set types)
|
|
23
23
|
- Data attributes
|
|
24
24
|
- Thread safety
|
|
25
|
+
- Rule chains that return the value of the first matching rule
|
|
25
26
|
|
|
26
27
|
Example Usage
|
|
27
28
|
-------------
|
|
@@ -30,17 +30,19 @@
|
|
|
30
30
|
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
31
|
#
|
|
32
32
|
|
|
33
|
-
__version__ = '5.0
|
|
33
|
+
__version__ = '5.2.0'
|
|
34
34
|
|
|
35
35
|
from .engine import resolve_attribute
|
|
36
36
|
from .engine import resolve_item
|
|
37
37
|
from .engine import type_resolver_from_dataclass
|
|
38
38
|
from .engine import type_resolver_from_dict
|
|
39
39
|
from .engine import type_resolver_from_sqlalchemy
|
|
40
|
+
from .engine import Chain
|
|
40
41
|
from .engine import Context
|
|
41
42
|
from .engine import Rule
|
|
42
43
|
|
|
43
44
|
from .errors import AttributeResolutionError
|
|
45
|
+
from .errors import ChainExhaustedError
|
|
44
46
|
from .errors import EngineError
|
|
45
47
|
from .errors import EvaluationError
|
|
46
48
|
from .errors import RuleSyntaxError
|
|
@@ -50,6 +52,8 @@ from .types import DataType
|
|
|
50
52
|
|
|
51
53
|
__all__ = (
|
|
52
54
|
'AttributeResolutionError',
|
|
55
|
+
'Chain',
|
|
56
|
+
'ChainExhaustedError',
|
|
53
57
|
'Context',
|
|
54
58
|
'DataType',
|
|
55
59
|
'EngineError',
|
|
@@ -247,7 +247,7 @@ class ExpressionBase(ASTNodeBase):
|
|
|
247
247
|
|
|
248
248
|
def _new_value(self, *args: Any, **kwargs: Any) -> Any:
|
|
249
249
|
# perform a context aware load of value
|
|
250
|
-
value = coerce_value(*args, **kwargs)
|
|
250
|
+
value = coerce_value(*args, int_max_digits=self.context.int_max_digits, **kwargs)
|
|
251
251
|
if isinstance(value, datetime.datetime) and value.tzinfo is None:
|
|
252
252
|
value = value.replace(tzinfo=self.context.default_timezone)
|
|
253
253
|
return value
|
|
@@ -295,7 +295,7 @@ class LiteralExpressionBase(ExpressionBase):
|
|
|
295
295
|
elif DataType.is_type(datatype, DataType.MAPPING):
|
|
296
296
|
value = tuple((cls.from_value(context, k), cls.from_value(context, v)) for k, v in value.items())
|
|
297
297
|
else:
|
|
298
|
-
value = coerce_value(value)
|
|
298
|
+
value = coerce_value(value, int_max_digits=context.int_max_digits)
|
|
299
299
|
return subclass(context, value)
|
|
300
300
|
|
|
301
301
|
def evaluate(self, thing: Any) -> Any:
|
|
@@ -38,6 +38,7 @@ from typing import Any, Callable
|
|
|
38
38
|
from ... import errors
|
|
39
39
|
from ...types import DataType, coerce_value
|
|
40
40
|
from ...types import _DataTypeDef
|
|
41
|
+
from ...types.coercion import _assert_int_bit_length
|
|
41
42
|
|
|
42
43
|
from ..base import _assert_is_bytes, _assert_is_natural_number, _assert_is_numeric, _assert_is_string, _assert_not_nullable, _is_reduced
|
|
43
44
|
from .base import BinaryExpressionBase
|
|
@@ -190,7 +191,16 @@ class BitwiseExpression(BinaryExpressionBase):
|
|
|
190
191
|
_assert_is_natural_number(left)
|
|
191
192
|
right = self.right.evaluate(thing)
|
|
192
193
|
_assert_is_natural_number(right)
|
|
193
|
-
|
|
194
|
+
left, right = int(left), int(right)
|
|
195
|
+
int_max_digits = self.context.int_max_digits
|
|
196
|
+
if op is operator.lshift and left:
|
|
197
|
+
# check the size of the result before shifting to avoid allocating an integer that is too large to convert
|
|
198
|
+
_assert_int_bit_length(left.bit_length() + right, int_max_digits)
|
|
199
|
+
try:
|
|
200
|
+
result = op(left, right)
|
|
201
|
+
except OverflowError:
|
|
202
|
+
raise errors.ArithmeticError('arithmetic error') from None
|
|
203
|
+
return coerce_value(result, int_max_digits=int_max_digits)
|
|
194
204
|
|
|
195
205
|
def _op_bitwise_set(self, op: Callable[[Any, Any], Any], thing: Any, left: Any) -> Any:
|
|
196
206
|
right = self.right.evaluate(thing)
|
|
@@ -46,7 +46,7 @@ class BinaryExpressionBase(ExpressionBase):
|
|
|
46
46
|
A base class for representing complex expressions composed of a left side and a right side, separated by an
|
|
47
47
|
operator.
|
|
48
48
|
"""
|
|
49
|
-
compatible_types: tuple[_DataTypeDef, ...] = (DataType.ARRAY, DataType.BOOLEAN, DataType.DATETIME, DataType.TIMEDELTA, DataType.FLOAT, DataType.MAPPING, DataType.NULL, DataType.SET, DataType.STRING)
|
|
49
|
+
compatible_types: tuple[_DataTypeDef, ...] = (DataType.ARRAY, DataType.BOOLEAN, DataType.BYTES, DataType.DATETIME, DataType.TIMEDELTA, DataType.FLOAT, DataType.FUNCTION, DataType.MAPPING, DataType.NULL, DataType.OBJECT, DataType.SET, DataType.STRING)
|
|
50
50
|
"""
|
|
51
51
|
A tuple containing the compatible data types that the left and right expressions must return. This can for example
|
|
52
52
|
be used to indicate that arithmetic operations are compatible with :py:attr:`~.DataType.FLOAT` but not
|
|
@@ -53,7 +53,6 @@ class LogicExpression(BinaryExpressionBase):
|
|
|
53
53
|
|
|
54
54
|
class ComparisonExpression(BinaryExpressionBase):
|
|
55
55
|
"""A class for representing comparison expressions from the grammar text such as equality checks."""
|
|
56
|
-
compatible_types: tuple[_DataTypeDef, ...] = BinaryExpressionBase.compatible_types + (DataType.OBJECT,)
|
|
57
56
|
def _op_eq(self, thing: Any) -> bool:
|
|
58
57
|
left_value = self.left.evaluate(thing)
|
|
59
58
|
right_value = self.right.evaluate(thing)
|
|
@@ -175,7 +175,7 @@ class GetAttributeExpression(ExpressionBase):
|
|
|
175
175
|
return resolved_obj
|
|
176
176
|
if resolved_obj is None and DataType.is_type(self.object.result_type, DataType.NULLABLE):
|
|
177
177
|
raise errors.EvaluationError(
|
|
178
|
-
"attribute access on a null value (use
|
|
178
|
+
"attribute access on a null value (use &. to safely navigate a NULLABLE expression)"
|
|
179
179
|
)
|
|
180
180
|
|
|
181
181
|
if self._object_type is not None:
|
|
@@ -417,7 +417,7 @@ class GetSliceExpression(ExpressionBase):
|
|
|
417
417
|
_assert_is_integer_number(resolved_stop)
|
|
418
418
|
resolved_stop = int(resolved_stop)
|
|
419
419
|
value = operator.getitem(resolved_obj, slice(resolved_start, resolved_stop))
|
|
420
|
-
return coerce_value(value, verify_type=False)
|
|
420
|
+
return coerce_value(value, verify_type=False, int_max_digits=self.context.int_max_digits)
|
|
421
421
|
|
|
422
422
|
def reduce(self) -> ExpressionBase:
|
|
423
423
|
if not _is_reduced(self.container, self.start, self.stop):
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
31
|
#
|
|
32
32
|
|
|
33
|
+
import collections.abc
|
|
33
34
|
from typing import TYPE_CHECKING, Any, Iterable
|
|
34
35
|
|
|
36
|
+
from ... import builtins as _builtins
|
|
35
37
|
from ... import errors
|
|
36
38
|
from ...types import DataType
|
|
37
39
|
from ...types import _CollectionDataTypeDef, _DataTypeDef, _FunctionDataTypeDef, _MappingDataTypeDef
|
|
@@ -41,12 +43,90 @@ from ..base import ExpressionBase, LiteralExpressionBase, _is_reduced
|
|
|
41
43
|
if TYPE_CHECKING:
|
|
42
44
|
from ...engine.context import Context
|
|
43
45
|
|
|
46
|
+
def _null_allowed(member_type: _DataTypeDef) -> bool:
|
|
47
|
+
"""Whether a member slot of *member_type* may hold ``None``."""
|
|
48
|
+
return member_type == DataType.UNDEFINED or member_type == DataType.NULL or DataType.is_type(member_type, DataType.NULLABLE)
|
|
49
|
+
|
|
50
|
+
_NullMemberPlan = tuple[bool, tuple[tuple[str, _DataTypeDef, bool, '_NullMemberPlan | None'], ...]]
|
|
51
|
+
|
|
52
|
+
# the containers that DataType.from_value treats as an ARRAY or a SET
|
|
53
|
+
_COLLECTION_TYPES = (collections.abc.Sequence, collections.abc.Set)
|
|
54
|
+
_NOT_COLLECTION_TYPES = (str, bytes, bytearray)
|
|
55
|
+
|
|
56
|
+
def _null_member_plan(declared: _DataTypeDef) -> '_NullMemberPlan | None':
|
|
57
|
+
"""
|
|
58
|
+
Precompute where a value of the compound type *declared* may hold a ``None`` that its member type doesn't allow,
|
|
59
|
+
for :py:func:`_find_null_member`. Each slot is a kind of member (``'key'``, ``'value'`` or ``'member'``), its
|
|
60
|
+
declared type, whether that type allows ``None`` and the plan for its own members.
|
|
61
|
+
|
|
62
|
+
:return: Whether the value is a mapping and the slots to check, or ``None`` when there is nothing to check.
|
|
63
|
+
"""
|
|
64
|
+
declared = DataType.NULLABLE.unwrap(declared)
|
|
65
|
+
slots: tuple[tuple[str, _DataTypeDef], ...]
|
|
66
|
+
if isinstance(declared, _MappingDataTypeDef):
|
|
67
|
+
is_mapping = True
|
|
68
|
+
slots = (('key', declared.key_type), ('value', declared.value_type))
|
|
69
|
+
elif isinstance(declared, _CollectionDataTypeDef):
|
|
70
|
+
is_mapping = False
|
|
71
|
+
slots = (('member', declared.value_type),)
|
|
72
|
+
else:
|
|
73
|
+
return None
|
|
74
|
+
plan = []
|
|
75
|
+
for kind, member_type in slots:
|
|
76
|
+
null_allowed = _null_allowed(member_type)
|
|
77
|
+
nested = _null_member_plan(member_type)
|
|
78
|
+
if null_allowed and nested is None:
|
|
79
|
+
continue
|
|
80
|
+
plan.append((kind, member_type, null_allowed, nested))
|
|
81
|
+
if not plan:
|
|
82
|
+
return None
|
|
83
|
+
return is_mapping, tuple(plan)
|
|
84
|
+
|
|
85
|
+
def _find_null_member(plan: _NullMemberPlan, value: Any) -> tuple[str, _DataTypeDef] | None:
|
|
86
|
+
"""
|
|
87
|
+
Find a ``None`` member of *value* in a slot that doesn't allow one, including nested members, following a plan from
|
|
88
|
+
:py:func:`_null_member_plan`. :py:attr:`~DataType.MAPPING` keys are checked against the key type and values against
|
|
89
|
+
the value type.
|
|
90
|
+
|
|
91
|
+
:return: The kind of member (``'key'``, ``'value'`` or ``'member'``) and its declared type, or ``None``.
|
|
92
|
+
"""
|
|
93
|
+
is_mapping, slots = plan
|
|
94
|
+
if is_mapping:
|
|
95
|
+
if not isinstance(value, collections.abc.Mapping):
|
|
96
|
+
return None
|
|
97
|
+
elif type(value) not in (tuple, set) and (not isinstance(value, _COLLECTION_TYPES) or isinstance(value, _NOT_COLLECTION_TYPES)):
|
|
98
|
+
return None
|
|
99
|
+
container_value: Any = value
|
|
100
|
+
members: Iterable[Any]
|
|
101
|
+
for kind, member_type, null_allowed, nested in slots:
|
|
102
|
+
if kind == 'key':
|
|
103
|
+
members = container_value.keys()
|
|
104
|
+
elif kind == 'value':
|
|
105
|
+
members = container_value.values()
|
|
106
|
+
else:
|
|
107
|
+
members = container_value
|
|
108
|
+
if nested is None:
|
|
109
|
+
# null_allowed is False, otherwise the slot would have been skipped
|
|
110
|
+
for member in members:
|
|
111
|
+
if member is None:
|
|
112
|
+
return kind, member_type
|
|
113
|
+
continue
|
|
114
|
+
for member in members:
|
|
115
|
+
if member is None:
|
|
116
|
+
if not null_allowed:
|
|
117
|
+
return kind, member_type
|
|
118
|
+
else:
|
|
119
|
+
found = _find_null_member(nested, member)
|
|
120
|
+
if found is not None:
|
|
121
|
+
return found
|
|
122
|
+
return None
|
|
123
|
+
|
|
44
124
|
class SymbolExpression(ExpressionBase):
|
|
45
125
|
"""
|
|
46
126
|
A class representing a symbol name to be resolved at evaluation time with the help of a
|
|
47
127
|
:py:class:`~rule_engine.engine.Context` object.
|
|
48
128
|
"""
|
|
49
|
-
__slots__ = ('name', 'result_type', 'scope')
|
|
129
|
+
__slots__ = ('name', 'result_type', 'scope', '_null_plan_cache')
|
|
50
130
|
def __init__(self, context: 'Context', name: str, scope: str | None = None) -> None:
|
|
51
131
|
"""
|
|
52
132
|
:param context: The context to use for evaluating the expression.
|
|
@@ -62,6 +142,8 @@ class SymbolExpression(ExpressionBase):
|
|
|
62
142
|
if type_hint is not None:
|
|
63
143
|
self.result_type = type_hint
|
|
64
144
|
self.scope = scope
|
|
145
|
+
# the type the cached plan was made for and the plan, see _accept_members
|
|
146
|
+
self._null_plan_cache: tuple[_DataTypeDef, _NullMemberPlan | None] | None = None
|
|
65
147
|
|
|
66
148
|
def __repr__(self) -> str:
|
|
67
149
|
return "<{0} name={1!r} >".format(self.__class__.__name__, self.name)
|
|
@@ -104,19 +186,56 @@ class SymbolExpression(ExpressionBase):
|
|
|
104
186
|
assert isinstance(effective_type, (_CollectionDataTypeDef, _MappingDataTypeDef))
|
|
105
187
|
assert isinstance(value_type, (_CollectionDataTypeDef, _MappingDataTypeDef))
|
|
106
188
|
if effective_type.value_type == DataType.UNDEFINED:
|
|
107
|
-
return value
|
|
189
|
+
return self._accept_members(effective_type, value)
|
|
108
190
|
if value_type.value_type == DataType.UNDEFINED:
|
|
109
|
-
return value
|
|
110
|
-
if
|
|
111
|
-
|
|
191
|
+
return self._accept_members(effective_type, value)
|
|
192
|
+
if not self._is_re_groups(effective_type):
|
|
193
|
+
if effective_type.value_type != DataType.NULL and not effective_type.value_type_nullable:
|
|
194
|
+
# for a MAPPING this checks the keys, not the values; a None key is fine when the key type allows
|
|
195
|
+
# it, but a None value found alongside it stays an error
|
|
196
|
+
has_null = any(v is None for v in value)
|
|
197
|
+
if has_null and isinstance(effective_type, _MappingDataTypeDef) and _null_allowed(effective_type.key_type):
|
|
198
|
+
has_null = any(v is None for v in value.values())
|
|
199
|
+
if has_null:
|
|
200
|
+
raise errors.SymbolTypeError(
|
|
201
|
+
self.name,
|
|
202
|
+
is_value=value,
|
|
203
|
+
is_type=_with_null_members(value_type, value),
|
|
204
|
+
expected_type=self.result_type
|
|
205
|
+
)
|
|
112
206
|
if DataType.is_compatible(effective_type.value_type, value_type.value_type):
|
|
113
|
-
return value
|
|
207
|
+
return self._accept_members(effective_type, value)
|
|
114
208
|
|
|
115
209
|
# if the type is null, return the value (treat null as a special case)
|
|
116
210
|
if value_type == DataType.NULL:
|
|
117
211
|
return value
|
|
118
212
|
|
|
119
|
-
raise errors.SymbolTypeError(
|
|
213
|
+
raise errors.SymbolTypeError(
|
|
214
|
+
self.name,
|
|
215
|
+
is_value=value,
|
|
216
|
+
is_type=_with_null_members(value_type, value),
|
|
217
|
+
expected_type=self.result_type
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _is_re_groups(self, effective_type: _DataTypeDef) -> bool:
|
|
221
|
+
# $re_groups holds None for groups that did not participate in the match (see _RE_GROUPS_TYPE); the type object
|
|
222
|
+
# can be passed on to other symbols (e.g. comprehension variables) so check the symbol site too
|
|
223
|
+
return self.scope == _builtins.Builtins.scope_name and effective_type is _builtins._RE_GROUPS_TYPE
|
|
224
|
+
|
|
225
|
+
def _accept_members(self, effective_type: _DataTypeDef, value: Any) -> Any:
|
|
226
|
+
# accept a compound value whose members passed the checks above, but warn about a None member where the member
|
|
227
|
+
# type doesn't allow one because v6.0 will raise SymbolTypeError for it
|
|
228
|
+
if (self.scope, self.name) in self.context._null_member_warned or self._is_re_groups(effective_type):
|
|
229
|
+
return value
|
|
230
|
+
cache = self._null_plan_cache
|
|
231
|
+
if cache is None or cache[0] is not effective_type:
|
|
232
|
+
cache = self._null_plan_cache = (effective_type, _null_member_plan(effective_type))
|
|
233
|
+
plan = cache[1]
|
|
234
|
+
found = None if plan is None else _find_null_member(plan, value)
|
|
235
|
+
# a variable assigned by an expression (e.g. a comprehension) holds a value that was checked where it came from
|
|
236
|
+
if found is not None and not (self.scope is None and self.context._is_assigned(self.name)):
|
|
237
|
+
self.context._warn_null_member(self.name, self.scope, *found)
|
|
238
|
+
return value
|
|
120
239
|
|
|
121
240
|
def to_graphviz(self, digraph: Any, *args: Any, **kwargs: Any) -> None:
|
|
122
241
|
digraph.node(str(id(self)), "{}\nname={!r}".format(self.__class__.__name__, self.name))
|
|
@@ -218,3 +337,13 @@ class FunctionCallExpression(ExpressionBase):
|
|
|
218
337
|
for idx, argument in enumerate(self.arguments, 1):
|
|
219
338
|
argument.to_graphviz(digraph, *args, **kwargs)
|
|
220
339
|
digraph.edge(str(id(self)), str(id(argument)), label="argument #{}".format(idx))
|
|
340
|
+
|
|
341
|
+
def _with_null_members(value_type: _DataTypeDef, value: Any) -> _DataTypeDef:
|
|
342
|
+
"""
|
|
343
|
+
Wrap the member type of the ARRAY or SET type *value_type* that was inferred from *value* in NULLABLE when *value*
|
|
344
|
+
has null members, which the inferred member type leaves out.
|
|
345
|
+
"""
|
|
346
|
+
if isinstance(value_type, _CollectionDataTypeDef) and value_type.value_type != DataType.UNDEFINED:
|
|
347
|
+
if any(member is None for member in value):
|
|
348
|
+
return value_type(DataType.NULLABLE(value_type.value_type))
|
|
349
|
+
return value_type
|
|
@@ -109,12 +109,12 @@ class FloatExpression(LiteralExpressionBase):
|
|
|
109
109
|
"""Literal float expressions representing numerical values."""
|
|
110
110
|
result_type: _DataTypeDef = DataType.FLOAT
|
|
111
111
|
def __init__(self, context: 'Context', value: Any, **kwargs: Any) -> None:
|
|
112
|
-
value = coerce_value(value)
|
|
112
|
+
value = coerce_value(value, int_max_digits=context.int_max_digits)
|
|
113
113
|
super(FloatExpression, self).__init__(context, value, **kwargs)
|
|
114
114
|
|
|
115
115
|
@classmethod
|
|
116
116
|
def from_string(cls, context: 'Context', string: str) -> 'FloatExpression':
|
|
117
|
-
return cls(context, parse_float(string))
|
|
117
|
+
return cls(context, parse_float(string, int_max_digits=context.int_max_digits))
|
|
118
118
|
|
|
119
119
|
class FunctionExpression(LiteralExpressionBase):
|
|
120
120
|
"""Literal mapping expression representing a function."""
|
|
@@ -41,7 +41,7 @@ from typing import Any, Callable, Iterable, Iterator, Mapping
|
|
|
41
41
|
|
|
42
42
|
from . import errors
|
|
43
43
|
from . import types
|
|
44
|
-
from .parser.utilities import
|
|
44
|
+
from .parser.utilities import _parse_float, parse_datetime, parse_timedelta
|
|
45
45
|
|
|
46
46
|
import dateutil.tz
|
|
47
47
|
|
|
@@ -92,6 +92,11 @@ def _builtins_split(string: str, sep: str | None = None, maxsplit: Any = None) -
|
|
|
92
92
|
raise errors.FunctionCallError('argument #3 (maxsplit) must be a natural number')
|
|
93
93
|
return tuple(string.split(sep=sep, maxsplit=maxsplit))
|
|
94
94
|
|
|
95
|
+
# The type of the $re_groups symbol that Context provides. A group that did not participate in the match is None, but
|
|
96
|
+
# the members are typed as STRING rather than NULLABLE(STRING) so that rules using them as strings (e.g.
|
|
97
|
+
# $re_groups[0] =~ "...") keep parsing. SymbolExpression checks for this exact object and allows None members for it.
|
|
98
|
+
_RE_GROUPS_TYPE = types.DataType.ARRAY(types.DataType.STRING)
|
|
99
|
+
|
|
95
100
|
class BuiltinValueGenerator(object):
|
|
96
101
|
"""
|
|
97
102
|
A class used as a wrapper for builtin values to differentiate between a value that is a function and a value that
|
|
@@ -191,7 +196,8 @@ class Builtins(collections.abc.Mapping):
|
|
|
191
196
|
'min': min,
|
|
192
197
|
'filter': _builtin_filter,
|
|
193
198
|
'parse_datetime': BuiltinValueGenerator(_builtin_parse_datetime_generator),
|
|
194
|
-
'
|
|
199
|
+
# an int result is converted using the context's int_max_digits like other function results
|
|
200
|
+
'parse_float': _parse_float,
|
|
195
201
|
'parse_timedelta': parse_timedelta,
|
|
196
202
|
'random': _builtin_random,
|
|
197
203
|
'range': _builtin_range,
|
|
@@ -37,17 +37,25 @@ import decimal
|
|
|
37
37
|
import functools
|
|
38
38
|
import math
|
|
39
39
|
import re
|
|
40
|
-
from typing import Any, Callable, Iterable, Sequence, Sized
|
|
40
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence, Sized
|
|
41
41
|
|
|
42
42
|
from .. import errors
|
|
43
43
|
from .. import parser
|
|
44
44
|
from .. import types
|
|
45
45
|
from ..suggestions import suggest_symbol
|
|
46
46
|
from ..types import _CollectionDataTypeDef, _DataTypeDef
|
|
47
|
+
from ..types.coercion import _assert_int_digits
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from .context import Context
|
|
51
|
+
|
|
52
|
+
def _float_op(value: decimal.Decimal, op: Callable[[decimal.Decimal], Any], int_max_digits: int | None = None) -> Any:
|
|
49
53
|
if value.is_nan() or value.is_infinite():
|
|
50
54
|
return value
|
|
55
|
+
if not value.is_zero():
|
|
56
|
+
# the integer result has at least adjusted() + 1 digits, check that before creating it because converting a
|
|
57
|
+
# Decimal to an integer takes time that grows quadratically with its size
|
|
58
|
+
_assert_int_digits(value.adjusted() + 1, int_max_digits)
|
|
51
59
|
return op(value)
|
|
52
60
|
|
|
53
61
|
def _value_to_ary_result_type(object_type: _DataTypeDef) -> _DataTypeDef:
|
|
@@ -70,15 +78,17 @@ def _value_with_result_type(name: str, object_type: _DataTypeDef) -> _DataTypeDe
|
|
|
70
78
|
return types.DataType.FUNCTION(name, argument_types=(object_type,), return_type=types.DataType.BOOLEAN)
|
|
71
79
|
|
|
72
80
|
class _AttributeResolverFunction(object):
|
|
73
|
-
__slots__ = ('function', 'type_resolver')
|
|
81
|
+
__slots__ = ('function', 'type_resolver', 'needs_context')
|
|
74
82
|
def __init__(
|
|
75
83
|
self,
|
|
76
84
|
function: Callable[..., Any],
|
|
77
85
|
*,
|
|
78
86
|
result_type: _DataTypeDef,
|
|
79
|
-
type_resolver: Callable[[_DataTypeDef], _DataTypeDef] | Any
|
|
87
|
+
type_resolver: Callable[[_DataTypeDef], _DataTypeDef] | Any,
|
|
88
|
+
needs_context: bool = False
|
|
80
89
|
) -> None:
|
|
81
90
|
self.function = function
|
|
91
|
+
self.needs_context = needs_context
|
|
82
92
|
if result_type and result_type is not types.DataType.UNDEFINED:
|
|
83
93
|
if not types.DataType.is_definition(result_type):
|
|
84
94
|
raise TypeError('result_type must be a types.DataType definition')
|
|
@@ -98,34 +108,46 @@ class _AttributeResolverFunction(object):
|
|
|
98
108
|
|
|
99
109
|
class _AttributeResolver(object):
|
|
100
110
|
class attribute(object):
|
|
101
|
-
__slots__ = ('types', 'name', 'result_type', 'type_resolver')
|
|
111
|
+
__slots__ = ('types', 'name', 'result_type', 'type_resolver', 'needs_context')
|
|
102
112
|
type_map: dict[_DataTypeDef, dict[str, _AttributeResolverFunction]] = collections.defaultdict(dict)
|
|
103
113
|
def __init__(
|
|
104
114
|
self,
|
|
105
115
|
name: str,
|
|
106
116
|
*data_types: _DataTypeDef,
|
|
107
117
|
result_type: _DataTypeDef = types.DataType.UNDEFINED,
|
|
108
|
-
type_resolver: Callable[[_DataTypeDef], _DataTypeDef] | Any = errors.UNDEFINED
|
|
118
|
+
type_resolver: Callable[[_DataTypeDef], _DataTypeDef] | Any = errors.UNDEFINED,
|
|
119
|
+
needs_context: bool = False
|
|
109
120
|
) -> None:
|
|
110
121
|
self.types = data_types
|
|
111
122
|
self.name = name
|
|
112
123
|
self.result_type = result_type
|
|
113
124
|
self.type_resolver = type_resolver
|
|
125
|
+
# whether the function takes the context that the attribute is being resolved in as the context keyword
|
|
126
|
+
# argument
|
|
127
|
+
self.needs_context = needs_context
|
|
114
128
|
|
|
115
129
|
def __call__(self, function: Callable[..., Any]) -> Callable[..., Any]:
|
|
116
130
|
for type_ in self.types:
|
|
117
|
-
self.type_map[type_][self.name] = _AttributeResolverFunction(
|
|
131
|
+
self.type_map[type_][self.name] = _AttributeResolverFunction(
|
|
132
|
+
function,
|
|
133
|
+
result_type=self.result_type,
|
|
134
|
+
type_resolver=self.type_resolver,
|
|
135
|
+
needs_context=self.needs_context
|
|
136
|
+
)
|
|
118
137
|
return function
|
|
119
138
|
|
|
120
|
-
def __call__(self, thing: Any, object_: Any, name: str) -> Any:
|
|
139
|
+
def __call__(self, thing: Any, object_: Any, name: str, *, context: 'Context') -> Any:
|
|
121
140
|
try:
|
|
122
141
|
object_type = types.DataType.from_value(object_)
|
|
123
142
|
except TypeError:
|
|
124
143
|
# if the object can't be mapped to a supported type, raise a resolution error
|
|
125
144
|
raise errors.AttributeResolutionError(name, object_, thing=thing) from None
|
|
126
145
|
resolver = self._get_resolver(object_type, name, thing=thing)
|
|
127
|
-
|
|
128
|
-
|
|
146
|
+
if resolver.needs_context:
|
|
147
|
+
value = resolver.function(self, object_, context=context)
|
|
148
|
+
else:
|
|
149
|
+
value = resolver.function(self, object_)
|
|
150
|
+
value = types.coerce_value(value, int_max_digits=context.int_max_digits)
|
|
129
151
|
value_type = types.DataType.from_value(value)
|
|
130
152
|
expected_value_type = resolver.resolve_type(value_type)
|
|
131
153
|
if types.DataType.is_compatible(expected_value_type, value_type):
|
|
@@ -218,13 +240,13 @@ class _AttributeResolver(object):
|
|
|
218
240
|
def datetime_zone_name(self, value: datetime.datetime) -> str | None:
|
|
219
241
|
return value.tzname()
|
|
220
242
|
|
|
221
|
-
@attribute('ceiling', types.DataType.FLOAT, result_type=types.DataType.FLOAT)
|
|
222
|
-
def float_ceiling(self, value: decimal.Decimal) -> decimal.Decimal | int:
|
|
223
|
-
return _float_op(value, math.ceil)
|
|
243
|
+
@attribute('ceiling', types.DataType.FLOAT, result_type=types.DataType.FLOAT, needs_context=True)
|
|
244
|
+
def float_ceiling(self, value: decimal.Decimal, *, context: 'Context') -> decimal.Decimal | int:
|
|
245
|
+
return _float_op(value, math.ceil, context.int_max_digits)
|
|
224
246
|
|
|
225
|
-
@attribute('floor', types.DataType.FLOAT, result_type=types.DataType.FLOAT)
|
|
226
|
-
def float_floor(self, value: decimal.Decimal) -> decimal.Decimal | int:
|
|
227
|
-
return _float_op(value, math.floor)
|
|
247
|
+
@attribute('floor', types.DataType.FLOAT, result_type=types.DataType.FLOAT, needs_context=True)
|
|
248
|
+
def float_floor(self, value: decimal.Decimal, *, context: 'Context') -> decimal.Decimal | int:
|
|
249
|
+
return _float_op(value, math.floor, context.int_max_digits)
|
|
228
250
|
|
|
229
251
|
@attribute('is_nan', types.DataType.FLOAT, result_type=types.DataType.BOOLEAN)
|
|
230
252
|
def float_is_nan(self, value: decimal.Decimal) -> bool:
|
|
@@ -283,7 +305,12 @@ class _AttributeResolver(object):
|
|
|
283
305
|
match = re.match(r'^(' + parser.Parser.get_token_regex('FLOAT') + ')$', value)
|
|
284
306
|
if match is None:
|
|
285
307
|
return decimal.Decimal('nan')
|
|
286
|
-
|
|
308
|
+
literal = match.group(0)
|
|
309
|
+
if literal.isdigit():
|
|
310
|
+
# python doesn't allow leading zeros in decimal integers but they are allowed here, like they are in the
|
|
311
|
+
# forms with a decimal point or exponent (e.g. 01.5 and 01e5)
|
|
312
|
+
literal = literal.lstrip('0') or '0'
|
|
313
|
+
return parser.literal_eval(literal)
|
|
287
314
|
|
|
288
315
|
@attribute('to_int', types.DataType.STRING, result_type=types.DataType.FLOAT)
|
|
289
316
|
def string_to_int(self, value: str) -> decimal.Decimal:
|