rule-engine 5.0.2__tar.gz → 5.1.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.1.0}/PKG-INFO +2 -1
- {rule_engine-5.0.2 → rule_engine-5.1.0}/README.rst +1 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/__init__.py +5 -1
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/engine/__init__.py +1 -0
- rule_engine-5.1.0/lib/rule_engine/engine/chain.py +289 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/engine/context.py +3 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/errors.py +24 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine.egg-info/PKG-INFO +2 -1
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine.egg-info/SOURCES.txt +1 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/LICENSE +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/_vendor/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/_vendor/ply/LICENSE +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/_vendor/ply/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/_vendor/ply/lex.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/_vendor/ply/yacc.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/base.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/binary/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/binary/arithmetic.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/binary/base.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/binary/comparison.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/expression/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/expression/access.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/expression/control.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/expression/resolution.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/ast/literal.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/builtins.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/debug_ast.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/debug_repl.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/engine/_attribute_resolver.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/engine/rule.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/parser/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/parser/base.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/parser/utilities.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/suggestions.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/__init__.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/_object.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/coercion.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/dataclass.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/datatype.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/definitions.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine/types/sqlalchemy.py +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine.egg-info/dependency_links.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine.egg-info/requires.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/lib/rule_engine.egg-info/top_level.txt +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.0}/pyproject.toml +0 -0
- {rule_engine-5.0.2 → rule_engine-5.1.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.1.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.1.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',
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
#
|
|
4
|
+
# rule_engine/engine/chain.py
|
|
5
|
+
#
|
|
6
|
+
# Redistribution and use in source and binary forms, with or without
|
|
7
|
+
# modification, are permitted provided that the following conditions are
|
|
8
|
+
# met:
|
|
9
|
+
#
|
|
10
|
+
# * Redistributions of source code must retain the above copyright
|
|
11
|
+
# notice, this list of conditions and the following disclaimer.
|
|
12
|
+
# * Redistributions in binary form must reproduce the above
|
|
13
|
+
# copyright notice, this list of conditions and the following disclaimer
|
|
14
|
+
# in the documentation and/or other materials provided with the
|
|
15
|
+
# distribution.
|
|
16
|
+
# * Neither the name of the project nor the names of its
|
|
17
|
+
# contributors may be used to endorse or promote products derived from
|
|
18
|
+
# this software without specific prior written permission.
|
|
19
|
+
#
|
|
20
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
21
|
+
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
22
|
+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
23
|
+
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
24
|
+
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
25
|
+
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
26
|
+
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
27
|
+
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
28
|
+
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
29
|
+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
#
|
|
32
|
+
|
|
33
|
+
import collections.abc
|
|
34
|
+
import operator
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
from typing import Any, Iterable, Iterator, NamedTuple, TypeVar, overload
|
|
38
|
+
|
|
39
|
+
from .. import errors
|
|
40
|
+
from .context import Context
|
|
41
|
+
from .rule import Rule
|
|
42
|
+
|
|
43
|
+
class ChainEntry(NamedTuple):
|
|
44
|
+
"""
|
|
45
|
+
A single entry within a :py:class:`.Chain`, pairing a rule with the value that is returned when it matches.
|
|
46
|
+
|
|
47
|
+
.. versionadded:: 5.1.0
|
|
48
|
+
"""
|
|
49
|
+
rule: Rule
|
|
50
|
+
"""The :py:class:`.Rule` that objects are evaluated against."""
|
|
51
|
+
value: Any
|
|
52
|
+
"""The value that is returned by :py:meth:`.Chain.evaluate` when :py:attr:`.rule` matches."""
|
|
53
|
+
|
|
54
|
+
def _check_rule(rule: Any, name: str = 'chain rule') -> None:
|
|
55
|
+
if not isinstance(rule, (str, Rule)):
|
|
56
|
+
raise TypeError("{0} must be a str or Rule instance, not {1}".format(name, type(rule).__name__))
|
|
57
|
+
|
|
58
|
+
def _unpack_entry(position: int, entry: Any) -> tuple[Any, Any]:
|
|
59
|
+
# only accept real sequences, iterating a str or mapping would silently split it into a bogus (rule, value) pair
|
|
60
|
+
if not isinstance(entry, collections.abc.Sequence) or isinstance(entry, (str, bytes, bytearray)):
|
|
61
|
+
raise TypeError("chain entry #{0} must be a (rule, value) pair, not {1}".format(
|
|
62
|
+
position,
|
|
63
|
+
type(entry).__name__
|
|
64
|
+
))
|
|
65
|
+
if len(entry) != 2:
|
|
66
|
+
raise ValueError("chain entry #{0} has length {1}; 2 is required".format(position, len(entry)))
|
|
67
|
+
_check_rule(entry[0], "chain entry #{0} rule".format(position))
|
|
68
|
+
return entry[0], entry[1]
|
|
69
|
+
|
|
70
|
+
_ChainT = TypeVar('_ChainT', bound='Chain')
|
|
71
|
+
|
|
72
|
+
class Chain(object):
|
|
73
|
+
"""
|
|
74
|
+
An ordered collection of rules, each of which is paired with a value. An object is evaluated against each rule in
|
|
75
|
+
order and the value of the first rule that matches it is returned, much like a packet traversing a chain in iptables
|
|
76
|
+
where the value is the target (e.g. ``ACCEPT`` or ``DROP``) and the default value is the chain's policy.
|
|
77
|
+
|
|
78
|
+
A rule matches when :py:meth:`.Rule.matches` returns a truthy value. Exceptions raised while evaluating a rule, such
|
|
79
|
+
as a :py:exc:`~rule_engine.errors.SymbolResolutionError`, are not caught and stop the evaluation.
|
|
80
|
+
|
|
81
|
+
Evaluating a chain does not acquire a lock. Each evaluation uses the entries that were present when it started, so
|
|
82
|
+
one thread can modify a chain while others are evaluating it. Modifications are serialized so concurrent changes
|
|
83
|
+
are not lost, however adding or removing an entry copies the others, so a large chain is best built by passing its
|
|
84
|
+
entries to the constructor. A chain should not be modified from a ``__del__`` method or a :py:mod:`weakref`
|
|
85
|
+
finalizer because the garbage collector can run them while a modification is in progress, which would deadlock.
|
|
86
|
+
A chain can be pickled as long as its context, rules and values can be.
|
|
87
|
+
|
|
88
|
+
Indexes start at 0, unlike the rule numbers used by iptables, and negative indexes count from the end of the chain.
|
|
89
|
+
Slicing a chain returns a new chain with the entries in the slice and the same :py:attr:`.default_value` and
|
|
90
|
+
:py:attr:`.context`. Adding or removing entries in either chain afterwards does not change the other.
|
|
91
|
+
|
|
92
|
+
.. versionadded:: 5.1.0
|
|
93
|
+
"""
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
entries: Iterable[tuple[Rule | str, Any]] | None = None,
|
|
97
|
+
*,
|
|
98
|
+
default_value: Any = errors.UNDEFINED,
|
|
99
|
+
context: Context | None = None
|
|
100
|
+
) -> None:
|
|
101
|
+
"""
|
|
102
|
+
:param entries: An optional iterable of ``(rule, value)`` pairs to initialize the chain with, in order. Each
|
|
103
|
+
rule can be either a :py:class:`.Rule` instance or the text of one, which will be compiled using
|
|
104
|
+
*context*.
|
|
105
|
+
:param default_value: The value that :py:meth:`.evaluate` returns when no rule matches. If this is
|
|
106
|
+
:py:data:`~rule_engine.errors.UNDEFINED` (the default), a
|
|
107
|
+
:py:exc:`~rule_engine.errors.ChainExhaustedError` is raised instead.
|
|
108
|
+
:param context: The context to use for compiling rules that are specified as text. If not specified, a new
|
|
109
|
+
default :py:class:`.Context` is created and shared by all of them. :py:class:`.Rule` instances are used
|
|
110
|
+
as-is and keep their own context.
|
|
111
|
+
:type context: :py:class:`.Context`
|
|
112
|
+
"""
|
|
113
|
+
if context is None:
|
|
114
|
+
context = Context()
|
|
115
|
+
self._lock = threading.Lock()
|
|
116
|
+
self.context = context
|
|
117
|
+
"""The context that is used for compiling rules that are specified as text."""
|
|
118
|
+
self.default_value = default_value
|
|
119
|
+
"""The *default_value* parameter from :py:meth:`~__init__`."""
|
|
120
|
+
self._entries: tuple[ChainEntry, ...] = ()
|
|
121
|
+
if entries is not None:
|
|
122
|
+
# validate every entry before compiling any of them so an invalid one doesn't add symbols to the context
|
|
123
|
+
pairs = [_unpack_entry(position, entry) for position, entry in enumerate(entries)]
|
|
124
|
+
self._entries = tuple(self._new_entry(rule, value) for rule, value in pairs)
|
|
125
|
+
|
|
126
|
+
@overload
|
|
127
|
+
def __getitem__(self, index: int) -> ChainEntry: ...
|
|
128
|
+
|
|
129
|
+
@overload
|
|
130
|
+
def __getitem__(self: _ChainT, index: slice) -> _ChainT: ...
|
|
131
|
+
|
|
132
|
+
def __getitem__(self, index: int | slice) -> 'ChainEntry | Chain':
|
|
133
|
+
if isinstance(index, slice):
|
|
134
|
+
chain = self.__class__(default_value=self.default_value, context=self.context)
|
|
135
|
+
# the entries are immutable so the new chain can share them
|
|
136
|
+
chain._entries = self._entries[index]
|
|
137
|
+
return chain
|
|
138
|
+
index = operator.index(index)
|
|
139
|
+
try:
|
|
140
|
+
return self._entries[index]
|
|
141
|
+
except IndexError:
|
|
142
|
+
raise IndexError('chain index out of range') from None
|
|
143
|
+
|
|
144
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
145
|
+
return {'context': self.context, 'default_value': self.default_value, 'entries': self._entries}
|
|
146
|
+
|
|
147
|
+
def __iter__(self) -> Iterator[ChainEntry]:
|
|
148
|
+
# return the tuple's iterator directly so the entries are captured when iter() is called
|
|
149
|
+
return iter(self._entries)
|
|
150
|
+
|
|
151
|
+
def __len__(self) -> int:
|
|
152
|
+
return len(self._entries)
|
|
153
|
+
|
|
154
|
+
def __repr__(self) -> str:
|
|
155
|
+
return "<{0} entries={1} default_value={2!r} >".format(
|
|
156
|
+
self.__class__.__name__,
|
|
157
|
+
len(self._entries),
|
|
158
|
+
self.default_value
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def __setstate__(self, state: dict[str, Any]) -> None:
|
|
162
|
+
self.context = state['context']
|
|
163
|
+
self.default_value = state['default_value']
|
|
164
|
+
self._entries = state['entries']
|
|
165
|
+
# recreate the lock which can not be pickled
|
|
166
|
+
self._lock = threading.Lock()
|
|
167
|
+
|
|
168
|
+
def _new_entry(self, rule: Rule | str, value: Any) -> ChainEntry:
|
|
169
|
+
_check_rule(rule)
|
|
170
|
+
if isinstance(rule, str):
|
|
171
|
+
rule = Rule(rule, context=self.context)
|
|
172
|
+
return ChainEntry(rule, value)
|
|
173
|
+
|
|
174
|
+
def append(self, rule: Rule | str, value: Any) -> ChainEntry:
|
|
175
|
+
"""
|
|
176
|
+
Add a new entry to the end of the chain, like ``iptables --append``.
|
|
177
|
+
|
|
178
|
+
:param rule: The rule for the new entry, either a :py:class:`.Rule` instance or the text of one, which will be
|
|
179
|
+
compiled using :py:attr:`.context`.
|
|
180
|
+
:param value: The value to return when *rule* matches.
|
|
181
|
+
:return: The new entry.
|
|
182
|
+
:rtype: :py:class:`.ChainEntry`
|
|
183
|
+
"""
|
|
184
|
+
entry = self._new_entry(rule, value)
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._entries = self._entries + (entry,)
|
|
187
|
+
return entry
|
|
188
|
+
|
|
189
|
+
def delete(self, index: int) -> ChainEntry:
|
|
190
|
+
"""
|
|
191
|
+
Remove the entry at *index* from the chain, like ``iptables --delete``. Indexes start at 0 and negative indexes
|
|
192
|
+
count from the end of the chain.
|
|
193
|
+
|
|
194
|
+
:param int index: The index of the entry to remove.
|
|
195
|
+
:return: The entry that was removed.
|
|
196
|
+
:rtype: :py:class:`.ChainEntry`
|
|
197
|
+
:raises IndexError: There is no entry at *index*.
|
|
198
|
+
"""
|
|
199
|
+
index = operator.index(index)
|
|
200
|
+
with self._lock:
|
|
201
|
+
entries = list(self._entries)
|
|
202
|
+
try:
|
|
203
|
+
entry = entries.pop(index)
|
|
204
|
+
except (IndexError, OverflowError):
|
|
205
|
+
raise IndexError('chain index out of range') from None
|
|
206
|
+
self._entries = tuple(entries)
|
|
207
|
+
return entry
|
|
208
|
+
|
|
209
|
+
def evaluate(self, thing: Any) -> Any:
|
|
210
|
+
"""
|
|
211
|
+
Evaluate *thing* against each rule in the chain and return the value of the first one that matches. If no rule
|
|
212
|
+
matches, the :py:attr:`.default_value` is returned.
|
|
213
|
+
|
|
214
|
+
:param thing: The object on which to apply the rules.
|
|
215
|
+
:return: The value of the first matching rule, or the default value.
|
|
216
|
+
:raises ChainExhaustedError: No rule matched and :py:attr:`.default_value` is
|
|
217
|
+
:py:data:`~rule_engine.errors.UNDEFINED`.
|
|
218
|
+
"""
|
|
219
|
+
default_value = self.default_value
|
|
220
|
+
entry = self.find(thing)
|
|
221
|
+
if entry is not None:
|
|
222
|
+
return entry.value
|
|
223
|
+
if default_value is errors.UNDEFINED:
|
|
224
|
+
raise errors.ChainExhaustedError(thing=thing)
|
|
225
|
+
return default_value
|
|
226
|
+
|
|
227
|
+
def find(self, thing: Any) -> ChainEntry | None:
|
|
228
|
+
"""
|
|
229
|
+
Evaluate *thing* against each rule in the chain and return the first entry that matches. Unlike
|
|
230
|
+
:py:meth:`.evaluate`, the default value is not used. This can be used to determine which rule matched, and
|
|
231
|
+
:py:meth:`.index` can then be used to determine its position.
|
|
232
|
+
|
|
233
|
+
:param thing: The object on which to apply the rules.
|
|
234
|
+
:return: The first matching :py:class:`.ChainEntry`, or ``None`` if no rule matched.
|
|
235
|
+
"""
|
|
236
|
+
for entry in self._entries:
|
|
237
|
+
if entry.rule.matches(thing):
|
|
238
|
+
return entry
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def flush(self) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Remove all entries from the chain, like ``iptables --flush``. The :py:attr:`.default_value` is unchanged.
|
|
244
|
+
"""
|
|
245
|
+
with self._lock:
|
|
246
|
+
entries, self._entries = self._entries, ()
|
|
247
|
+
# release the old entries after the lock so any finalizers they trigger can modify the chain
|
|
248
|
+
del entries
|
|
249
|
+
|
|
250
|
+
def index(self, entry: ChainEntry) -> int:
|
|
251
|
+
"""
|
|
252
|
+
Return the index of *entry* in the chain, such as an entry that was returned by :py:meth:`.find`. Entries are
|
|
253
|
+
compared as tuples and a rule is only equal to itself, so an entry is only found in a chain that contains the
|
|
254
|
+
same rule, like the chain it came from or a slice of it.
|
|
255
|
+
|
|
256
|
+
:param entry: The entry to look up.
|
|
257
|
+
:type entry: :py:class:`.ChainEntry`
|
|
258
|
+
:return: The index of the first entry that is equal to *entry*.
|
|
259
|
+
:rtype: int
|
|
260
|
+
:raises ValueError: *entry* is not in the chain.
|
|
261
|
+
"""
|
|
262
|
+
# compare the entries here instead of using tuple.index so an error raised while comparing two values isn't
|
|
263
|
+
# mistaken for the entry being missing
|
|
264
|
+
for position, other in enumerate(self._entries):
|
|
265
|
+
if other is entry or other == entry:
|
|
266
|
+
return position
|
|
267
|
+
raise ValueError('entry is not in the chain')
|
|
268
|
+
|
|
269
|
+
def insert(self, index: int, rule: Rule | str, value: Any) -> ChainEntry:
|
|
270
|
+
"""
|
|
271
|
+
Insert a new entry into the chain before *index*, like ``iptables --insert``. Indexes start at 0 and negative
|
|
272
|
+
indexes count from the end of the chain. This follows the semantics of :py:meth:`list.insert`, so an *index*
|
|
273
|
+
past either end of the chain adds the entry to that end.
|
|
274
|
+
|
|
275
|
+
:param int index: The index to insert the new entry before.
|
|
276
|
+
:param rule: The rule for the new entry, either a :py:class:`.Rule` instance or the text of one, which will be
|
|
277
|
+
compiled using :py:attr:`.context`.
|
|
278
|
+
:param value: The value to return when *rule* matches.
|
|
279
|
+
:return: The new entry.
|
|
280
|
+
:rtype: :py:class:`.ChainEntry`
|
|
281
|
+
"""
|
|
282
|
+
# clamp the index to the range list.insert supports, it then clamps it again to the ends of the chain
|
|
283
|
+
index = max(-sys.maxsize - 1, min(operator.index(index), sys.maxsize))
|
|
284
|
+
entry = self._new_entry(rule, value)
|
|
285
|
+
with self._lock:
|
|
286
|
+
entries = list(self._entries)
|
|
287
|
+
entries.insert(index, entry)
|
|
288
|
+
self._entries = tuple(entries)
|
|
289
|
+
return entry
|
|
@@ -326,6 +326,9 @@ class Context(object):
|
|
|
326
326
|
self.symbols = state['symbols']
|
|
327
327
|
self.default_timezone = state['default_timezone']
|
|
328
328
|
self.default_value = state['default_value']
|
|
329
|
+
if isinstance(self.default_value, errors._UNDEFINED):
|
|
330
|
+
# pickles created before UNDEFINED preserved its identity restore a copy of it instead of the singleton
|
|
331
|
+
self.default_value = errors.UNDEFINED
|
|
329
332
|
self.decimal_context = state['decimal_context']
|
|
330
333
|
self.mapping_attribute_lookup = state['mapping_attribute_lookup']
|
|
331
334
|
self._mapping_fallback_warned = state['_mapping_fallback_warned']
|
|
@@ -46,11 +46,17 @@ class _UNDEFINED(object):
|
|
|
46
46
|
__nonzero__ = __bool__
|
|
47
47
|
def __repr__(self) -> str:
|
|
48
48
|
return self.__name__
|
|
49
|
+
def __reduce__(self) -> str:
|
|
50
|
+
# restore the module-level singleton so identity checks (``is UNDEFINED``) still work after pickle or copy
|
|
51
|
+
return 'UNDEFINED'
|
|
49
52
|
UNDEFINED = _UNDEFINED()
|
|
50
53
|
"""
|
|
51
54
|
A sentinel value to specify that something is undefined. When evaluated, the value is falsy.
|
|
52
55
|
|
|
53
56
|
.. versionadded:: 2.0.0
|
|
57
|
+
|
|
58
|
+
.. versionchanged:: 5.1.0
|
|
59
|
+
The sentinel keeps its identity when it is pickled or copied.
|
|
54
60
|
"""
|
|
55
61
|
|
|
56
62
|
class EngineError(Exception):
|
|
@@ -357,3 +363,21 @@ class ArithmeticError(EvaluationError):
|
|
|
357
363
|
|
|
358
364
|
.. versionadded:: 5.0.0
|
|
359
365
|
"""
|
|
366
|
+
|
|
367
|
+
class ChainExhaustedError(EvaluationError):
|
|
368
|
+
"""
|
|
369
|
+
An error raised by :py:meth:`~rule_engine.engine.Chain.evaluate` when none of the rules in the chain match and the
|
|
370
|
+
chain does not have a default value.
|
|
371
|
+
|
|
372
|
+
.. versionadded:: 5.1.0
|
|
373
|
+
"""
|
|
374
|
+
def __init__(self, thing: Any = UNDEFINED) -> None:
|
|
375
|
+
"""
|
|
376
|
+
:param thing: The object that was evaluated by the chain.
|
|
377
|
+
"""
|
|
378
|
+
self.thing = thing
|
|
379
|
+
"""The object that was evaluated by the chain."""
|
|
380
|
+
super(ChainExhaustedError, self).__init__('no rule in the chain matched and no default value is set')
|
|
381
|
+
# EngineError doesn't initialize the arguments so they would include *thing* when it's passed positionally, set
|
|
382
|
+
# them to the message to keep its contents out of str(), tracebacks and anything that serializes them
|
|
383
|
+
self.args = (self.message,)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rule-engine
|
|
3
|
-
Version: 5.0
|
|
3
|
+
Version: 5.1.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
|
-------------
|
|
@@ -30,6 +30,7 @@ lib/rule_engine/ast/expression/control.py
|
|
|
30
30
|
lib/rule_engine/ast/expression/resolution.py
|
|
31
31
|
lib/rule_engine/engine/__init__.py
|
|
32
32
|
lib/rule_engine/engine/_attribute_resolver.py
|
|
33
|
+
lib/rule_engine/engine/chain.py
|
|
33
34
|
lib/rule_engine/engine/context.py
|
|
34
35
|
lib/rule_engine/engine/rule.py
|
|
35
36
|
lib/rule_engine/parser/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|