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 ADDED
@@ -0,0 +1,5 @@
1
+ """TagHound: rule-based tagging for dicts and pandas DataFrames."""
2
+
3
+ from taghound.taghound import TagHound
4
+
5
+ __all__ = ["TagHound"]
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
+ }
@@ -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