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.
Files changed (51) hide show
  1. {rule_engine-5.0.2 → rule_engine-5.2.0}/PKG-INFO +2 -1
  2. {rule_engine-5.0.2 → rule_engine-5.2.0}/README.rst +1 -0
  3. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/__init__.py +5 -1
  4. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/base.py +2 -2
  5. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/arithmetic.py +11 -1
  6. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/base.py +1 -1
  7. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/comparison.py +0 -1
  8. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/access.py +2 -2
  9. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/resolution.py +136 -7
  10. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/literal.py +2 -2
  11. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/builtins.py +8 -2
  12. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/__init__.py +1 -0
  13. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/_attribute_resolver.py +44 -17
  14. rule_engine-5.2.0/lib/rule_engine/engine/chain.py +289 -0
  15. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/context.py +93 -8
  16. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/errors.py +71 -4
  17. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/__init__.py +19 -0
  18. rule_engine-5.2.0/lib/rule_engine/parser/__main__.py +38 -0
  19. rule_engine-5.2.0/lib/rule_engine/parser/_tables.py +241 -0
  20. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/base.py +26 -1
  21. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/parser/utilities.py +33 -12
  22. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/_object.py +13 -1
  23. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/coercion.py +93 -7
  24. rule_engine-5.2.0/lib/rule_engine/types/dataclass.py +290 -0
  25. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/datatype.py +42 -4
  26. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/definitions.py +115 -13
  27. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/PKG-INFO +2 -1
  28. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/SOURCES.txt +4 -0
  29. {rule_engine-5.0.2 → rule_engine-5.2.0}/pyproject.toml +2 -1
  30. rule_engine-5.2.0/setup.py +53 -0
  31. rule_engine-5.0.2/lib/rule_engine/types/dataclass.py +0 -149
  32. {rule_engine-5.0.2 → rule_engine-5.2.0}/LICENSE +0 -0
  33. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/__init__.py +0 -0
  34. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/LICENSE +0 -0
  35. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/__init__.py +0 -0
  36. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/lex.py +0 -0
  37. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/_vendor/ply/yacc.py +0 -0
  38. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/__init__.py +0 -0
  39. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/binary/__init__.py +0 -0
  40. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/__init__.py +0 -0
  41. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/ast/expression/control.py +0 -0
  42. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/debug_ast.py +0 -0
  43. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/debug_repl.py +0 -0
  44. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/engine/rule.py +0 -0
  45. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/suggestions.py +0 -0
  46. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/__init__.py +0 -0
  47. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine/types/sqlalchemy.py +0 -0
  48. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/dependency_links.txt +0 -0
  49. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/requires.txt +0 -0
  50. {rule_engine-5.0.2 → rule_engine-5.2.0}/lib/rule_engine.egg-info/top_level.txt +0 -0
  51. {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.2
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.2'
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
- return coerce_value(op(int(left), int(right)))
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 ?. to safely navigate a NULLABLE expression)"
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 effective_type.value_type != DataType.NULL and not effective_type.value_type_nullable and any(v is None for v in value):
111
- raise errors.SymbolTypeError(self.name, is_value=value, is_type=value_type, expected_type=self.result_type)
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(self.name, is_value=value, is_type=value_type, expected_type=self.result_type)
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 parse_datetime, parse_float, parse_timedelta
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
- 'parse_float': parse_float,
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,
@@ -32,3 +32,4 @@
32
32
 
33
33
  from .context import resolve_attribute, resolve_item, type_resolver_from_dataclass, type_resolver_from_dict, type_resolver_from_sqlalchemy, Context
34
34
  from .rule import Rule, DebugRule
35
+ from .chain import Chain, ChainEntry
@@ -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
- def _float_op(value: decimal.Decimal, op: Callable[[decimal.Decimal], Any]) -> Any:
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(function, result_type=self.result_type, type_resolver=self.type_resolver)
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
- value = resolver.function(self, object_)
128
- value = types.coerce_value(value)
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
- return parser.literal_eval(match.group(0))
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: