taghound 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.
- taghound/__init__.py +5 -0
- taghound/constants.py +87 -0
- taghound/exceptions.py +13 -0
- taghound/models.py +38 -0
- taghound/py.typed +0 -0
- taghound/scalar/__init__.py +1 -0
- taghound/scalar/function_builder.py +218 -0
- taghound/scalar/operators.py +91 -0
- taghound/serializers.py +129 -0
- taghound/taghound.py +283 -0
- taghound/vector/__init__.py +1 -0
- taghound/vector/function_builder.py +278 -0
- taghound-0.2.0.dist-info/METADATA +184 -0
- taghound-0.2.0.dist-info/RECORD +16 -0
- taghound-0.2.0.dist-info/WHEEL +4 -0
- taghound-0.2.0.dist-info/licenses/LICENSE +21 -0
taghound/__init__.py
ADDED
taghound/constants.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Shared enums, type aliases, and defaults for rule definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
PyEvalFn = Callable[[Mapping[str, Any]], bool]
|
|
12
|
+
PdEvalFn = Callable[[pd.DataFrame], pd.Series]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LogicalOperator(Enum):
|
|
16
|
+
"""Logical operators combining condition lists."""
|
|
17
|
+
|
|
18
|
+
AND = "and"
|
|
19
|
+
OR = "or"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ComparisonOperator(Enum):
|
|
23
|
+
"""Comparison operators usable in a condition's `op` field."""
|
|
24
|
+
|
|
25
|
+
EQUAL = "="
|
|
26
|
+
NOT_EQUAL = "!="
|
|
27
|
+
GREATER_THAN = ">"
|
|
28
|
+
GREATER_THAN_OR_EQUAL = ">="
|
|
29
|
+
LESS_THAN = "<"
|
|
30
|
+
LESS_THAN_OR_EQUAL = "<="
|
|
31
|
+
IS = "is"
|
|
32
|
+
IS_NOT = "is_not"
|
|
33
|
+
IN = "in"
|
|
34
|
+
NOT_IN = "not_in"
|
|
35
|
+
REGEX_MATCH = "~"
|
|
36
|
+
REGEX_NOT_MATCH = "!~"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ComparisonOperatorRegexOnly = {
|
|
40
|
+
ComparisonOperator.REGEX_MATCH,
|
|
41
|
+
ComparisonOperator.REGEX_NOT_MATCH,
|
|
42
|
+
}
|
|
43
|
+
ComparisonOperatorNumericOnly = {
|
|
44
|
+
ComparisonOperator.GREATER_THAN,
|
|
45
|
+
ComparisonOperator.GREATER_THAN_OR_EQUAL,
|
|
46
|
+
ComparisonOperator.LESS_THAN,
|
|
47
|
+
ComparisonOperator.LESS_THAN_OR_EQUAL,
|
|
48
|
+
}
|
|
49
|
+
ComparisonOperatorBoolOnly = {
|
|
50
|
+
ComparisonOperator.IS,
|
|
51
|
+
ComparisonOperator.IS_NOT,
|
|
52
|
+
}
|
|
53
|
+
ComparisonOperatorListOnly = {
|
|
54
|
+
ComparisonOperator.IN,
|
|
55
|
+
ComparisonOperator.NOT_IN,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RuleKey(Enum):
|
|
60
|
+
"""Top-level keys of a rule definition."""
|
|
61
|
+
|
|
62
|
+
ID = "id"
|
|
63
|
+
LABEL = "label"
|
|
64
|
+
WEIGHT = "weight"
|
|
65
|
+
INFO = "info"
|
|
66
|
+
ROOT_AND = LogicalOperator.AND.value
|
|
67
|
+
ROOT_OR = LogicalOperator.OR.value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ComparisonKey(Enum):
|
|
71
|
+
"""Keys of a single comparison condition."""
|
|
72
|
+
|
|
73
|
+
KEY = "key"
|
|
74
|
+
OPERATOR = "op"
|
|
75
|
+
VALUE = "value"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
DEFAULT_COMPARISON_OPERATOR = ComparisonOperator.EQUAL
|
|
79
|
+
""" Default comparison operator to use when not specified """
|
|
80
|
+
|
|
81
|
+
DEFAULT_WEIGHT = 0.0
|
|
82
|
+
""" Default weight to use when not specified """
|
|
83
|
+
|
|
84
|
+
# - This is better than \b...\b because it also matches cases like `C++`
|
|
85
|
+
# - Limitations: does not work when we need to match `.python` with `python` pattern
|
|
86
|
+
DEFAULT_REGEX_MERGE_PATTERN = r"(?<!\w)(?:{pattern})(?!\w)"
|
|
87
|
+
""" Pattern used to merge list of patterns into a single regex pattern """
|
taghound/exceptions.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised while parsing rule definitions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidOperatorError(Exception):
|
|
5
|
+
"""A logical node uses an operator other than `and`/`or`."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuleAndOrTogetherError(Exception):
|
|
9
|
+
"""A rule's root condition has both `and` and `or`."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingRootConditionError(Exception):
|
|
13
|
+
"""A rule's root condition has neither `and` nor `or`."""
|
taghound/models.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Data model for tag rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from taghound.constants import PdEvalFn, PyEvalFn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, eq=True, slots=True)
|
|
13
|
+
class TagRule:
|
|
14
|
+
"""A single tag rule: identity, metadata, and its prebuilt check functions."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
""" Unique identifier for the rule. """
|
|
18
|
+
|
|
19
|
+
label: str = field(compare=False)
|
|
20
|
+
""" The label for the rule. No uniqueness is enforced. """
|
|
21
|
+
|
|
22
|
+
weight: float = field(compare=False)
|
|
23
|
+
""" The weight of the rule. """
|
|
24
|
+
|
|
25
|
+
required_fields: set[str] = field(compare=False)
|
|
26
|
+
""" The required fields for the rule. """
|
|
27
|
+
|
|
28
|
+
info: str | None = field(compare=False, default=None)
|
|
29
|
+
""" Internal comments for the rule. """
|
|
30
|
+
|
|
31
|
+
scalar_check: PyEvalFn | None = field(compare=False, default=None)
|
|
32
|
+
""" Scalar function used to check if the rule is satisfied. """
|
|
33
|
+
|
|
34
|
+
vector_check: PdEvalFn | None = field(compare=False, default=None)
|
|
35
|
+
""" Vector function used to check if the rule is satisfied. """
|
|
36
|
+
|
|
37
|
+
data: Mapping[str, Any] = field(compare=False, default_factory=dict)
|
|
38
|
+
""" The original data used to create the TagRule object. """
|
taghound/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scalar (one mapping at a time) rule evaluation."""
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Build per-rule check functions that evaluate one data mapping at a time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from taghound.constants import (
|
|
10
|
+
DEFAULT_COMPARISON_OPERATOR,
|
|
11
|
+
ComparisonKey,
|
|
12
|
+
ComparisonOperator,
|
|
13
|
+
ComparisonOperatorListOnly,
|
|
14
|
+
ComparisonOperatorNumericOnly,
|
|
15
|
+
ComparisonOperatorRegexOnly,
|
|
16
|
+
LogicalOperator,
|
|
17
|
+
PyEvalFn,
|
|
18
|
+
RuleKey,
|
|
19
|
+
)
|
|
20
|
+
from taghound.exceptions import InvalidOperatorError, MissingRootConditionError
|
|
21
|
+
from taghound.scalar.operators import ComparisonOperatorMap, LogicalOperatorMap
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_scalar_function(data: Mapping[str, Any], merge_pattern: str) -> PyEvalFn:
|
|
25
|
+
"""Create a function that will evaluate the scalar condition.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
data (Mapping): The data to create the scalar function.
|
|
29
|
+
merge_pattern (str): The pattern to merge the regex patterns.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
PyEvalFn: The function that will evaluate the scalar condition.
|
|
33
|
+
"""
|
|
34
|
+
if RuleKey.ROOT_AND.value in data:
|
|
35
|
+
condition_fn = _create_logic_function(
|
|
36
|
+
{LogicalOperator.AND.value: data[RuleKey.ROOT_AND.value]},
|
|
37
|
+
merge_pattern=merge_pattern,
|
|
38
|
+
)
|
|
39
|
+
elif RuleKey.ROOT_OR.value in data:
|
|
40
|
+
condition_fn = _create_logic_function(
|
|
41
|
+
{LogicalOperator.OR.value: data[RuleKey.ROOT_OR.value]},
|
|
42
|
+
merge_pattern=merge_pattern,
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
raise MissingRootConditionError(
|
|
46
|
+
"Root condition must have either `and` or `or`."
|
|
47
|
+
)
|
|
48
|
+
return condition_fn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _create_logic_function(
|
|
52
|
+
params: Mapping[str, Sequence[Mapping[str, Any]]], merge_pattern: str
|
|
53
|
+
) -> Callable:
|
|
54
|
+
"""Create a function that will evaluate the logical operation (OR/AND).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
params (Mapping): The parameters for the logical operation.
|
|
58
|
+
merge_pattern (str): The pattern to merge the regex patterns.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Callable: The function that will evaluate the logical operation.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
InvalidOperatorError: If the operator is not valid.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
params = {
|
|
69
|
+
"and": [
|
|
70
|
+
{"key": "language", "op": "=", "value": "python"},
|
|
71
|
+
{"key": "year", "op": ">", "value": 1990},
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
fn = _make_logic_operation_function(params)
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
logical_operator = LogicalOperator(next(iter(params.keys())))
|
|
79
|
+
except (StopIteration, ValueError):
|
|
80
|
+
raise InvalidOperatorError(
|
|
81
|
+
f"Invalid operator. Must be one of {LogicalOperator.__members__}"
|
|
82
|
+
)
|
|
83
|
+
logical_fn = LogicalOperatorMap[logical_operator]
|
|
84
|
+
|
|
85
|
+
condition_fns = _parse_conditions(
|
|
86
|
+
params[logical_operator.value], merge_pattern=merge_pattern
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def logic_function(data: Mapping[str, Any]) -> bool:
|
|
90
|
+
return logical_fn(fn(data) for fn in condition_fns)
|
|
91
|
+
|
|
92
|
+
return logic_function
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_conditions(
|
|
96
|
+
conditions: Sequence[Mapping], merge_pattern: str
|
|
97
|
+
) -> list[Callable[[Mapping[str, Any]], bool]]:
|
|
98
|
+
"""Create a list of functions that will evaluate the conditions.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
conditions (list[Mapping]): The list of conditions to be evaluated.
|
|
102
|
+
merge_pattern (str): The pattern to merge the regex patterns.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
list[Callable]: The list of functions that will evaluate the conditions.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
```python
|
|
109
|
+
elems = [
|
|
110
|
+
{"key": "language", "op": "=", "value": "python"},
|
|
111
|
+
{"key": "year", "op": ">", "value": 1990},
|
|
112
|
+
]
|
|
113
|
+
fns = _make_condition_functions(elems)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(conditions, Sequence):
|
|
118
|
+
raise TypeError(f"Must be list. Given: `{type(conditions)}`")
|
|
119
|
+
|
|
120
|
+
comparison_fns = []
|
|
121
|
+
for condition in conditions:
|
|
122
|
+
if not isinstance(condition, Mapping):
|
|
123
|
+
raise TypeError(f"Expected a Mapping. Given: `{type(condition)}`")
|
|
124
|
+
if not condition:
|
|
125
|
+
raise ValueError("Empty Mapping found in logical conditions.")
|
|
126
|
+
|
|
127
|
+
if len(condition.keys()) == 1:
|
|
128
|
+
conditions_fn = _create_logic_function(
|
|
129
|
+
condition, merge_pattern=merge_pattern
|
|
130
|
+
)
|
|
131
|
+
comparison_fns.append(conditions_fn)
|
|
132
|
+
else:
|
|
133
|
+
fn = _make_comparison_condition_function(
|
|
134
|
+
key=condition[ComparisonKey.KEY.value],
|
|
135
|
+
value=condition[ComparisonKey.VALUE.value],
|
|
136
|
+
operator=condition.get(
|
|
137
|
+
ComparisonKey.OPERATOR.value, DEFAULT_COMPARISON_OPERATOR.value
|
|
138
|
+
),
|
|
139
|
+
merge_pattern=merge_pattern,
|
|
140
|
+
)
|
|
141
|
+
comparison_fns.append(fn)
|
|
142
|
+
return comparison_fns
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _make_comparison_condition_function(
|
|
146
|
+
key: str, value: object, operator: str, merge_pattern: str
|
|
147
|
+
) -> Callable[[Any], bool]:
|
|
148
|
+
"""Create a function that will evaluate the comparison condition.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
key (str): The key in the Mapping to be compared.
|
|
152
|
+
value (Any): The value to be compared.
|
|
153
|
+
operator (str): The operator used in the condition e.g `=`, `>`, ...
|
|
154
|
+
merge_pattern (str): The pattern to merge the regex patterns.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Callable: The function that will evaluate the comparison condition.
|
|
158
|
+
"""
|
|
159
|
+
operator_enum = ComparisonOperator(operator)
|
|
160
|
+
|
|
161
|
+
value, operator_enum = _validate_and_normalize_comparison_condition_data(
|
|
162
|
+
value, operator_enum, merge_pattern
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def comparison_condition_function(data: Mapping[str, Any]) -> bool:
|
|
166
|
+
return ComparisonOperatorMap[operator_enum](data, key, value)
|
|
167
|
+
|
|
168
|
+
return comparison_condition_function
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_and_normalize_comparison_condition_data(
|
|
172
|
+
value: object,
|
|
173
|
+
operator_enum: ComparisonOperator,
|
|
174
|
+
merge_pattern: str,
|
|
175
|
+
) -> tuple[Any, ComparisonOperator]:
|
|
176
|
+
"""Validate and normalize the value based on the operator.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
value (Any): The value to be validated
|
|
180
|
+
operator_enum (ComparisonOperator): The operator used in the condition
|
|
181
|
+
merge_pattern (str): The pattern to merge the regex patterns
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
tuple[Any, ComparisonOperator]: The normalized value and operator
|
|
185
|
+
"""
|
|
186
|
+
if operator_enum in ComparisonOperatorRegexOnly:
|
|
187
|
+
if isinstance(value, list):
|
|
188
|
+
pattern_str = "|".join(value)
|
|
189
|
+
elif isinstance(value, str):
|
|
190
|
+
pattern_str = value
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError(f"Invalid value for regex match: `{value}` ({type(value)}")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
value = re.compile(merge_pattern.format(pattern=pattern_str), re.IGNORECASE)
|
|
196
|
+
except re.error:
|
|
197
|
+
raise ValueError(f"Invalid regex pattern: `{pattern_str}`")
|
|
198
|
+
|
|
199
|
+
elif operator_enum in ComparisonOperatorNumericOnly:
|
|
200
|
+
if not isinstance(value, (int, float)):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Invalid value for number comparison: `{value}` ({type(value)})"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
elif operator_enum in ComparisonOperatorListOnly:
|
|
206
|
+
if not isinstance(value, (list, tuple, set)):
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Invalid value for list comparison: `{value}` ({type(value)}"
|
|
209
|
+
)
|
|
210
|
+
value = set(value)
|
|
211
|
+
|
|
212
|
+
elif isinstance(value, bool):
|
|
213
|
+
if operator_enum == ComparisonOperator.EQUAL:
|
|
214
|
+
operator_enum = ComparisonOperator.IS
|
|
215
|
+
elif operator_enum == ComparisonOperator.NOT_EQUAL:
|
|
216
|
+
operator_enum = ComparisonOperator.IS_NOT
|
|
217
|
+
|
|
218
|
+
return value, operator_enum
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Comparison and logical operator implementations for the scalar path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
|
|
9
|
+
from taghound.constants import ComparisonOperator, LogicalOperator
|
|
10
|
+
|
|
11
|
+
LogicalOperatorMap: dict[LogicalOperator, Callable] = {
|
|
12
|
+
LogicalOperator.AND: all,
|
|
13
|
+
LogicalOperator.OR: any,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def eval_equal(data: Mapping, key: str, value: bool | str | int | float) -> bool:
|
|
18
|
+
"""Return whether `data[key] == value`."""
|
|
19
|
+
return data[key] == value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def eval_not_equal(data: Mapping, key: str, value: bool | str | int | float) -> bool:
|
|
23
|
+
"""Return whether `data[key] != value`."""
|
|
24
|
+
return data[key] != value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def eval_greater_than(data: Mapping, key: str, value: int | float) -> bool:
|
|
28
|
+
"""Return whether `data[key] > value`."""
|
|
29
|
+
return data[key] > value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def eval_greater_than_or_equal(data: Mapping, key: str, value: int | float) -> bool:
|
|
33
|
+
"""Return whether `data[key] >= value`."""
|
|
34
|
+
return data[key] >= value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def eval_less_than(data: Mapping, key: str, value: int | float) -> bool:
|
|
38
|
+
"""Return whether `data[key] < value`."""
|
|
39
|
+
return data[key] < value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def eval_less_than_or_equal(data: Mapping, key: str, value: int | float) -> bool:
|
|
43
|
+
"""Return whether `data[key] <= value`."""
|
|
44
|
+
return data[key] <= value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def eval_in(data: Mapping, key: str, value: set[bool | str | int | float]) -> bool:
|
|
48
|
+
"""Return whether `data[key]` is a member of `value`."""
|
|
49
|
+
return data[key] in value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def eval_not_in(data: Mapping, key: str, value: set[bool | str | int | float]) -> bool:
|
|
53
|
+
"""Return whether `data[key]` is not a member of `value`."""
|
|
54
|
+
return data[key] not in value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def eval_is(data: Mapping, key: str, value: bool) -> bool:
|
|
58
|
+
"""Return whether `data[key] is value`."""
|
|
59
|
+
return data[key] is value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def eval_is_not(data: Mapping, key: str, value: bool) -> bool:
|
|
63
|
+
"""Return whether `data[key] is not value`."""
|
|
64
|
+
return data[key] is not value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def eval_regex_match(data: Mapping, key: str, value: re.Pattern[str]) -> bool:
|
|
68
|
+
"""Return whether the pattern matches anywhere in `data[key]`."""
|
|
69
|
+
return value.search(data[key]) is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def eval_regex_not_match(data: Mapping, key: str, value: re.Pattern[str]) -> bool:
|
|
73
|
+
"""Return whether the pattern matches nowhere in `data[key]`."""
|
|
74
|
+
return not value.search(data[key])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
ComparisonOperatorFn = Callable[[Mapping, str, Any], bool]
|
|
78
|
+
ComparisonOperatorMap: Mapping[ComparisonOperator, ComparisonOperatorFn] = {
|
|
79
|
+
ComparisonOperator.EQUAL: eval_equal,
|
|
80
|
+
ComparisonOperator.NOT_EQUAL: eval_not_equal,
|
|
81
|
+
ComparisonOperator.GREATER_THAN: eval_greater_than,
|
|
82
|
+
ComparisonOperator.GREATER_THAN_OR_EQUAL: eval_greater_than_or_equal,
|
|
83
|
+
ComparisonOperator.LESS_THAN: eval_less_than,
|
|
84
|
+
ComparisonOperator.LESS_THAN_OR_EQUAL: eval_less_than_or_equal,
|
|
85
|
+
ComparisonOperator.IN: eval_in,
|
|
86
|
+
ComparisonOperator.NOT_IN: eval_not_in,
|
|
87
|
+
ComparisonOperator.IS: eval_is,
|
|
88
|
+
ComparisonOperator.IS_NOT: eval_is_not,
|
|
89
|
+
ComparisonOperator.REGEX_MATCH: eval_regex_match,
|
|
90
|
+
ComparisonOperator.REGEX_NOT_MATCH: eval_regex_not_match,
|
|
91
|
+
}
|
taghound/serializers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Serialization of raw rule dicts into TagRule objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from taghound.constants import (
|
|
9
|
+
DEFAULT_REGEX_MERGE_PATTERN,
|
|
10
|
+
DEFAULT_WEIGHT,
|
|
11
|
+
ComparisonKey,
|
|
12
|
+
LogicalOperator,
|
|
13
|
+
RuleKey,
|
|
14
|
+
)
|
|
15
|
+
from taghound.models import TagRule
|
|
16
|
+
from taghound.exceptions import MissingRootConditionError, RuleAndOrTogetherError
|
|
17
|
+
from taghound.scalar.function_builder import create_scalar_function
|
|
18
|
+
from taghound.vector.function_builder import create_vector_function
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_tag_rule(
|
|
22
|
+
rule_data: Mapping[str, Any],
|
|
23
|
+
add_scalar_func: bool = True,
|
|
24
|
+
add_vector_fn: bool = True,
|
|
25
|
+
merge_pattern: str = DEFAULT_REGEX_MERGE_PATTERN,
|
|
26
|
+
) -> TagRule:
|
|
27
|
+
"""Create a TagRule object from the given data.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
rule_data (Mapping[str, Any]): The rule definition: `id`, optional `label`,
|
|
31
|
+
`weight` and `info`, plus a root `and` or `or` condition list.
|
|
32
|
+
add_scalar_func (bool): Whether to build the scalar check function.
|
|
33
|
+
add_vector_fn (bool): Whether to build the vector check function.
|
|
34
|
+
merge_pattern (str): The pattern used to merge regex patterns.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
TagRule: The TagRule object created from the data.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> rule = to_tag_rule(
|
|
41
|
+
... {
|
|
42
|
+
... "id": "python",
|
|
43
|
+
... "weight": 10,
|
|
44
|
+
... "and": [
|
|
45
|
+
... {"key": "language", "op": "=", "value": "python"},
|
|
46
|
+
... {"key": "year", "op": ">", "value": 1990},
|
|
47
|
+
... ],
|
|
48
|
+
... }
|
|
49
|
+
... )
|
|
50
|
+
>>> rule.id
|
|
51
|
+
'python'
|
|
52
|
+
>>> rule.weight
|
|
53
|
+
10.0
|
|
54
|
+
>>> sorted(rule.required_fields)
|
|
55
|
+
['language', 'year']
|
|
56
|
+
"""
|
|
57
|
+
tag_name = rule_data[RuleKey.ID.value]
|
|
58
|
+
weight = rule_data.get(RuleKey.WEIGHT.value, DEFAULT_WEIGHT)
|
|
59
|
+
label = rule_data.get(RuleKey.LABEL.value, tag_name)
|
|
60
|
+
info = rule_data.get(RuleKey.INFO.value, None)
|
|
61
|
+
|
|
62
|
+
# Validate
|
|
63
|
+
if not isinstance(tag_name, str):
|
|
64
|
+
raise ValueError(f"tag_name must be a string. Given: `{type(tag_name)}`")
|
|
65
|
+
|
|
66
|
+
if isinstance(weight, int):
|
|
67
|
+
weight = float(weight)
|
|
68
|
+
elif not isinstance(weight, float):
|
|
69
|
+
raise ValueError(f"weight must be a integer or float. Given: {type(weight)}")
|
|
70
|
+
if RuleKey.ROOT_AND.value in rule_data and RuleKey.ROOT_OR.value in rule_data:
|
|
71
|
+
raise RuleAndOrTogetherError(
|
|
72
|
+
"Cannot have both `and` and `or` in the same root condition."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
required_fields = (
|
|
77
|
+
get_required_keys_from_conditions(rule_data[RuleKey.ROOT_AND.value])
|
|
78
|
+
if RuleKey.ROOT_AND.value in rule_data
|
|
79
|
+
else get_required_keys_from_conditions(rule_data[RuleKey.ROOT_OR.value])
|
|
80
|
+
)
|
|
81
|
+
except KeyError:
|
|
82
|
+
raise MissingRootConditionError()
|
|
83
|
+
|
|
84
|
+
tag = TagRule(
|
|
85
|
+
id=tag_name,
|
|
86
|
+
label=label,
|
|
87
|
+
weight=weight,
|
|
88
|
+
info=info,
|
|
89
|
+
required_fields=required_fields,
|
|
90
|
+
scalar_check=(
|
|
91
|
+
create_scalar_function(rule_data, merge_pattern=merge_pattern)
|
|
92
|
+
if add_scalar_func
|
|
93
|
+
else None
|
|
94
|
+
),
|
|
95
|
+
vector_check=(
|
|
96
|
+
create_vector_function(rule_data, merge_pattern=merge_pattern)
|
|
97
|
+
if add_vector_fn
|
|
98
|
+
else None
|
|
99
|
+
),
|
|
100
|
+
data=rule_data,
|
|
101
|
+
)
|
|
102
|
+
return tag
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_required_keys_from_conditions(
|
|
106
|
+
conditions: Sequence[Mapping[str, Any]],
|
|
107
|
+
) -> set[str]:
|
|
108
|
+
"""Collect the data keys referenced by the conditions, recursing into nested and/or.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
conditions (Sequence[Mapping[str, Any]]): The conditions to scan.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
set[str]: The keys the conditions compare against.
|
|
115
|
+
"""
|
|
116
|
+
keys_set = set()
|
|
117
|
+
for condition in conditions:
|
|
118
|
+
if not isinstance(condition, Mapping):
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"Expected a Mapping. Given: `{type(condition)}`. Value: `{condition}`"
|
|
121
|
+
)
|
|
122
|
+
if ComparisonKey.KEY.value in condition:
|
|
123
|
+
keys_set.add(condition[ComparisonKey.KEY.value])
|
|
124
|
+
continue
|
|
125
|
+
for logical_operator in LogicalOperator:
|
|
126
|
+
nested = condition.get(logical_operator.value)
|
|
127
|
+
if isinstance(nested, Sequence) and not isinstance(nested, str):
|
|
128
|
+
keys_set.update(get_required_keys_from_conditions(nested))
|
|
129
|
+
return keys_set
|