fast-feature-engine 0.0.1__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.
- fast_feature_engine-0.0.1/PKG-INFO +19 -0
- fast_feature_engine-0.0.1/pyproject.toml +34 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/__init__.py +27 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/coercion/__init__.py +5 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/coercion/coercion.py +76 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/engine.py +79 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/errors/__init__.py +6 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/errors/base.py +9 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/errors/json_logic.py +7 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/evaluator/__init__.py +5 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/evaluator/evaluator.py +31 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/hashing/__init__.py +6 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/hashing/hasher.py +14 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/hashing/murmur3_hasher.py +14 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operator/__init__.py +6 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operator/operator.py +19 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operator/simple_operator.py +20 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/__init__.py +103 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/arithmetic.py +53 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/collection.py +98 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/comparison.py +54 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/data.py +67 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/logic.py +49 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/operators/targeting.py +87 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/py.typed +0 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/registry/__init__.py +5 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/registry/registry.py +25 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/semver/__init__.py +5 -0
- fast_feature_engine-0.0.1/src/fast_feature/engine/semver/semantic_version.py +44 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-feature-engine
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: JsonLogic targeting and evaluation engine for fast-feature.
|
|
5
|
+
Author: byunjuneseok
|
|
6
|
+
Author-email: byunjuneseok <byunjuneseok@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: fast-feature-core==0.0.1
|
|
18
|
+
Requires-Dist: mmh3>=4.0
|
|
19
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fast-feature-engine"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "JsonLogic targeting and evaluation engine for fast-feature."
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "byunjuneseok", email = "byunjuneseok@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: AsyncIO",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"fast-feature-core==0.0.1",
|
|
23
|
+
"mmh3>=4.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.11.17,<0.12.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[tool.uv.build-backend]
|
|
31
|
+
module-name = "fast_feature.engine"
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
fast-feature-core = { workspace = true }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .coercion import Coercion
|
|
4
|
+
from .engine import TargetingEngine
|
|
5
|
+
from .errors import EngineError, JsonLogicError
|
|
6
|
+
from .evaluator import JsonLogicEvaluator
|
|
7
|
+
from .hashing import Hasher, Murmur3Hasher
|
|
8
|
+
from .operator import Operator, SimpleOperator
|
|
9
|
+
from .operators import StandardOperators, TargetingOperators
|
|
10
|
+
from .registry import OperatorRegistry
|
|
11
|
+
from .semver import SemanticVersion
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TargetingEngine",
|
|
15
|
+
"JsonLogicEvaluator",
|
|
16
|
+
"OperatorRegistry",
|
|
17
|
+
"Operator",
|
|
18
|
+
"SimpleOperator",
|
|
19
|
+
"Coercion",
|
|
20
|
+
"Hasher",
|
|
21
|
+
"Murmur3Hasher",
|
|
22
|
+
"SemanticVersion",
|
|
23
|
+
"StandardOperators",
|
|
24
|
+
"TargetingOperators",
|
|
25
|
+
"EngineError",
|
|
26
|
+
"JsonLogicError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.errors import JsonLogicError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Coercion:
|
|
9
|
+
"""JavaScript-aligned type coercion shared by the operators."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def is_truthy(value: Any) -> bool:
|
|
13
|
+
if value is None or value is False:
|
|
14
|
+
return False
|
|
15
|
+
if isinstance(value, bool):
|
|
16
|
+
return value
|
|
17
|
+
if isinstance(value, int | float):
|
|
18
|
+
return value != 0
|
|
19
|
+
if isinstance(value, str):
|
|
20
|
+
return value != ""
|
|
21
|
+
if isinstance(value, list):
|
|
22
|
+
return len(value) > 0
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
return True
|
|
25
|
+
return bool(value)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def to_str(value: Any) -> str:
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
if value is True:
|
|
32
|
+
return "true"
|
|
33
|
+
if value is False:
|
|
34
|
+
return "false"
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def to_number(value: Any) -> float:
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return int(value)
|
|
41
|
+
if isinstance(value, int | float):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
try:
|
|
45
|
+
if any(c in value for c in ".eE"):
|
|
46
|
+
return float(value)
|
|
47
|
+
return int(value)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise JsonLogicError(f"cannot convert {value!r} to a number") from exc
|
|
50
|
+
raise JsonLogicError(f"cannot convert {value!r} to a number")
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def soft_equals(cls, a: Any, b: Any) -> bool:
|
|
54
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
55
|
+
return cls.is_truthy(a) is cls.is_truthy(b)
|
|
56
|
+
if isinstance(a, str) or isinstance(b, str):
|
|
57
|
+
return cls.to_str(a) == cls.to_str(b)
|
|
58
|
+
return bool(a == b)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def hard_equals(a: Any, b: Any) -> bool:
|
|
62
|
+
if type(a) is not type(b):
|
|
63
|
+
return False
|
|
64
|
+
return bool(a == b)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def less_than(cls, a: Any, b: Any) -> bool:
|
|
68
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
69
|
+
return a < b
|
|
70
|
+
return cls.to_number(a) < cls.to_number(b)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def less_than_or_equal(cls, a: Any, b: Any) -> bool:
|
|
74
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
75
|
+
return a <= b
|
|
76
|
+
return cls.to_number(a) <= cls.to_number(b)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fast_feature.core.evaluation import ErrorCode, EvaluationOutcome, Reason
|
|
4
|
+
from fast_feature.core.flag import Flag, FlagState
|
|
5
|
+
from fast_feature.core.types import EvaluationContext, JsonValue
|
|
6
|
+
|
|
7
|
+
from .errors import JsonLogicError
|
|
8
|
+
from .evaluator import JsonLogicEvaluator
|
|
9
|
+
from .hashing import Hasher, Murmur3Hasher
|
|
10
|
+
from .operators import StandardOperators, TargetingOperators
|
|
11
|
+
from .registry import OperatorRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TargetingEngine:
|
|
15
|
+
"""Resolves a flag against a context using the following evaluation semantics.
|
|
16
|
+
|
|
17
|
+
- ``DISABLED`` flag -> reason ``DISABLED``
|
|
18
|
+
- no targeting rule -> reason ``STATIC``
|
|
19
|
+
- targeting returns a known variant -> reason ``TARGETING_MATCH``
|
|
20
|
+
- targeting returns ``null`` -> reason ``DEFAULT``
|
|
21
|
+
- targeting errors / unknown variant -> reason ``ERROR`` (``PARSE_ERROR``)
|
|
22
|
+
|
|
23
|
+
In every non-match case the flag's default variant value is returned, so a
|
|
24
|
+
caller always receives a usable value.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, hasher: Hasher | None = None) -> None:
|
|
28
|
+
registry = OperatorRegistry(StandardOperators.mapping()).extended_with(
|
|
29
|
+
TargetingOperators.mapping(hasher or Murmur3Hasher())
|
|
30
|
+
)
|
|
31
|
+
self._evaluator = JsonLogicEvaluator(registry)
|
|
32
|
+
|
|
33
|
+
def evaluate(self, flag: Flag, context: EvaluationContext | None = None) -> EvaluationOutcome:
|
|
34
|
+
if flag.state is FlagState.DISABLED:
|
|
35
|
+
return self._resolved(flag, flag.default_variant, Reason.DISABLED)
|
|
36
|
+
|
|
37
|
+
if not flag.targeting:
|
|
38
|
+
return self._resolved(flag, flag.default_variant, Reason.STATIC)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = self._evaluator.apply(flag.targeting, self._build_data(flag, context))
|
|
42
|
+
except JsonLogicError as exc:
|
|
43
|
+
return self._errored(flag, str(exc))
|
|
44
|
+
|
|
45
|
+
if result is None:
|
|
46
|
+
return self._resolved(flag, flag.default_variant, Reason.DEFAULT)
|
|
47
|
+
if not isinstance(result, str) or not flag.has_variant(result):
|
|
48
|
+
return self._errored(
|
|
49
|
+
flag, f"targeting resolved to {result!r}, which is not a defined variant"
|
|
50
|
+
)
|
|
51
|
+
return self._resolved(flag, result, Reason.TARGETING_MATCH)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _build_data(flag: Flag, context: EvaluationContext | None) -> dict[str, JsonValue]:
|
|
55
|
+
data: dict[str, JsonValue] = dict(context or {})
|
|
56
|
+
data["$flag"] = {"key": flag.key}
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _resolved(flag: Flag, variant: str, reason: Reason) -> EvaluationOutcome:
|
|
61
|
+
return EvaluationOutcome(
|
|
62
|
+
key=flag.key,
|
|
63
|
+
reason=reason,
|
|
64
|
+
value=flag.value_of(variant),
|
|
65
|
+
variant=variant,
|
|
66
|
+
metadata=flag.metadata or None,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _errored(flag: Flag, details: str) -> EvaluationOutcome:
|
|
71
|
+
return EvaluationOutcome(
|
|
72
|
+
key=flag.key,
|
|
73
|
+
reason=Reason.ERROR,
|
|
74
|
+
value=flag.default_value,
|
|
75
|
+
variant=flag.default_variant,
|
|
76
|
+
metadata=flag.metadata or None,
|
|
77
|
+
error_code=ErrorCode.PARSE_ERROR,
|
|
78
|
+
error_details=details,
|
|
79
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EngineError(Exception):
|
|
5
|
+
"""Base class for all targeting-engine errors.
|
|
6
|
+
|
|
7
|
+
Dependents catch this at their boundary and re-raise as their own base
|
|
8
|
+
error (with chaining) rather than coupling to the concrete subclasses.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.errors import JsonLogicError
|
|
6
|
+
from fast_feature.engine.registry import OperatorRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonLogicEvaluator:
|
|
10
|
+
"""Evaluates a JsonLogic rule against a data object using a registry."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, registry: OperatorRegistry) -> None:
|
|
13
|
+
self._registry = registry
|
|
14
|
+
|
|
15
|
+
def apply(self, rule: Any, data: Any = None) -> Any:
|
|
16
|
+
if data is None:
|
|
17
|
+
data = {}
|
|
18
|
+
if isinstance(rule, list):
|
|
19
|
+
return [self.apply(item, data) for item in rule]
|
|
20
|
+
if not self._is_operation(rule):
|
|
21
|
+
return rule
|
|
22
|
+
name, raw = next(iter(rule.items()))
|
|
23
|
+
args = raw if isinstance(raw, list) else [raw]
|
|
24
|
+
operator = self._registry.resolve(name)
|
|
25
|
+
if operator is None:
|
|
26
|
+
raise JsonLogicError(f"Unrecognized operation {name!r}")
|
|
27
|
+
return operator.apply(self, args, data)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def _is_operation(rule: Any) -> bool:
|
|
31
|
+
return isinstance(rule, dict) and len(rule) == 1 and isinstance(next(iter(rule)), str)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class Hasher(Protocol):
|
|
8
|
+
"""A deterministic hash used for ``fractional`` bucketing."""
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def max_value(self) -> int:
|
|
12
|
+
"""The largest value ``hash`` can return (used to normalise to a ratio)."""
|
|
13
|
+
|
|
14
|
+
def hash(self, key: str) -> int: ...
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mmh3
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Murmur3Hasher:
|
|
7
|
+
"""MurmurHash3 (x86, 32-bit) hasher backed by the ``mmh3`` library."""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def max_value(self) -> int:
|
|
11
|
+
return 0xFFFFFFFF
|
|
12
|
+
|
|
13
|
+
def hash(self, key: str) -> int:
|
|
14
|
+
return mmh3.hash(key, signed=False)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Operator(ABC):
|
|
11
|
+
"""A JsonLogic operation.
|
|
12
|
+
|
|
13
|
+
The operator owns how its arguments are evaluated, so control-flow
|
|
14
|
+
operators (``if``, ``and``, iterators, ...) can evaluate lazily.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
19
|
+
"""Evaluate this operation's ``args`` against ``data``."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from .operator import Operator
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimpleOperator(Operator):
|
|
13
|
+
"""An operator whose arguments are all eagerly evaluated before computing."""
|
|
14
|
+
|
|
15
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
16
|
+
return self.compute(*[evaluator.apply(arg, data) for arg in args])
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def compute(self, *values: Any) -> Any:
|
|
20
|
+
"""Compute the result from already-evaluated argument ``values``."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fast_feature.engine.hashing import Hasher
|
|
4
|
+
from fast_feature.engine.operator import Operator
|
|
5
|
+
|
|
6
|
+
from .arithmetic import (
|
|
7
|
+
AddOperator,
|
|
8
|
+
DivideOperator,
|
|
9
|
+
MaxOperator,
|
|
10
|
+
MinOperator,
|
|
11
|
+
ModuloOperator,
|
|
12
|
+
MultiplyOperator,
|
|
13
|
+
SubtractOperator,
|
|
14
|
+
)
|
|
15
|
+
from .collection import (
|
|
16
|
+
AllOperator,
|
|
17
|
+
CatOperator,
|
|
18
|
+
FilterOperator,
|
|
19
|
+
InOperator,
|
|
20
|
+
MapOperator,
|
|
21
|
+
MergeOperator,
|
|
22
|
+
NoneMatchOperator,
|
|
23
|
+
ReduceOperator,
|
|
24
|
+
SomeOperator,
|
|
25
|
+
SubstrOperator,
|
|
26
|
+
)
|
|
27
|
+
from .comparison import (
|
|
28
|
+
EqualsOperator,
|
|
29
|
+
GreaterThanOperator,
|
|
30
|
+
GreaterThanOrEqualOperator,
|
|
31
|
+
LessThanOperator,
|
|
32
|
+
LessThanOrEqualOperator,
|
|
33
|
+
NotEqualsOperator,
|
|
34
|
+
StrictEqualsOperator,
|
|
35
|
+
StrictNotEqualsOperator,
|
|
36
|
+
)
|
|
37
|
+
from .data import MissingOperator, MissingSomeOperator, VarOperator
|
|
38
|
+
from .logic import AndOperator, IfOperator, NotOperator, OrOperator, ToBoolOperator
|
|
39
|
+
from .targeting import (
|
|
40
|
+
EndsWithOperator,
|
|
41
|
+
FractionalOperator,
|
|
42
|
+
SemVerOperator,
|
|
43
|
+
StartsWithOperator,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StandardOperators:
|
|
48
|
+
"""The JsonLogic operators available in every evaluation."""
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def mapping(cls) -> dict[str, Operator]:
|
|
52
|
+
return {
|
|
53
|
+
"var": VarOperator(),
|
|
54
|
+
"missing": MissingOperator(),
|
|
55
|
+
"missing_some": MissingSomeOperator(),
|
|
56
|
+
"if": IfOperator(),
|
|
57
|
+
"?:": IfOperator(),
|
|
58
|
+
"and": AndOperator(),
|
|
59
|
+
"or": OrOperator(),
|
|
60
|
+
"!": NotOperator(),
|
|
61
|
+
"!!": ToBoolOperator(),
|
|
62
|
+
"==": EqualsOperator(),
|
|
63
|
+
"!=": NotEqualsOperator(),
|
|
64
|
+
"===": StrictEqualsOperator(),
|
|
65
|
+
"!==": StrictNotEqualsOperator(),
|
|
66
|
+
"<": LessThanOperator(),
|
|
67
|
+
"<=": LessThanOrEqualOperator(),
|
|
68
|
+
">": GreaterThanOperator(),
|
|
69
|
+
">=": GreaterThanOrEqualOperator(),
|
|
70
|
+
"+": AddOperator(),
|
|
71
|
+
"-": SubtractOperator(),
|
|
72
|
+
"*": MultiplyOperator(),
|
|
73
|
+
"/": DivideOperator(),
|
|
74
|
+
"%": ModuloOperator(),
|
|
75
|
+
"min": MinOperator(),
|
|
76
|
+
"max": MaxOperator(),
|
|
77
|
+
"in": InOperator(),
|
|
78
|
+
"cat": CatOperator(),
|
|
79
|
+
"substr": SubstrOperator(),
|
|
80
|
+
"merge": MergeOperator(),
|
|
81
|
+
"map": MapOperator(),
|
|
82
|
+
"filter": FilterOperator(),
|
|
83
|
+
"reduce": ReduceOperator(),
|
|
84
|
+
"all": AllOperator(),
|
|
85
|
+
"some": SomeOperator(),
|
|
86
|
+
"none": NoneMatchOperator(),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TargetingOperators:
|
|
91
|
+
"""The extension operators used for flag targeting."""
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def mapping(cls, hasher: Hasher) -> dict[str, Operator]:
|
|
95
|
+
return {
|
|
96
|
+
"starts_with": StartsWithOperator(),
|
|
97
|
+
"ends_with": EndsWithOperator(),
|
|
98
|
+
"sem_ver": SemVerOperator(),
|
|
99
|
+
"fractional": FractionalOperator(hasher),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = ["StandardOperators", "TargetingOperators"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.coercion import Coercion
|
|
6
|
+
from fast_feature.engine.errors import JsonLogicError
|
|
7
|
+
from fast_feature.engine.operator import SimpleOperator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AddOperator(SimpleOperator):
|
|
11
|
+
def compute(self, *values: Any) -> float:
|
|
12
|
+
return sum(Coercion.to_number(value) for value in values)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubtractOperator(SimpleOperator):
|
|
16
|
+
def compute(self, *values: Any) -> float:
|
|
17
|
+
if len(values) == 1:
|
|
18
|
+
return -Coercion.to_number(values[0])
|
|
19
|
+
return Coercion.to_number(values[0]) - Coercion.to_number(values[1])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MultiplyOperator(SimpleOperator):
|
|
23
|
+
def compute(self, *values: Any) -> float:
|
|
24
|
+
product: float = 1
|
|
25
|
+
for value in values:
|
|
26
|
+
product *= Coercion.to_number(value)
|
|
27
|
+
return product
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DivideOperator(SimpleOperator):
|
|
31
|
+
def compute(self, *values: Any) -> float:
|
|
32
|
+
try:
|
|
33
|
+
return Coercion.to_number(values[0]) / Coercion.to_number(values[1])
|
|
34
|
+
except ZeroDivisionError as exc:
|
|
35
|
+
raise JsonLogicError("division by zero") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ModuloOperator(SimpleOperator):
|
|
39
|
+
def compute(self, *values: Any) -> float:
|
|
40
|
+
try:
|
|
41
|
+
return Coercion.to_number(values[0]) % Coercion.to_number(values[1])
|
|
42
|
+
except ZeroDivisionError as exc:
|
|
43
|
+
raise JsonLogicError("modulo by zero") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MinOperator(SimpleOperator):
|
|
47
|
+
def compute(self, *values: Any) -> float:
|
|
48
|
+
return min(Coercion.to_number(value) for value in values)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MaxOperator(SimpleOperator):
|
|
52
|
+
def compute(self, *values: Any) -> float:
|
|
53
|
+
return max(Coercion.to_number(value) for value in values)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.coercion import Coercion
|
|
6
|
+
from fast_feature.engine.operator import Operator, SimpleOperator
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InOperator(SimpleOperator):
|
|
13
|
+
def compute(self, *values: Any) -> bool:
|
|
14
|
+
needle, haystack = values[0], values[1]
|
|
15
|
+
if isinstance(haystack, str):
|
|
16
|
+
return Coercion.to_str(needle) in haystack
|
|
17
|
+
if isinstance(haystack, list):
|
|
18
|
+
return needle in haystack
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CatOperator(SimpleOperator):
|
|
23
|
+
def compute(self, *values: Any) -> str:
|
|
24
|
+
return "".join(Coercion.to_str(value) for value in values)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SubstrOperator(SimpleOperator):
|
|
28
|
+
def compute(self, *values: Any) -> str:
|
|
29
|
+
text = Coercion.to_str(values[0])
|
|
30
|
+
start = int(values[1])
|
|
31
|
+
if len(values) < 3:
|
|
32
|
+
return text[start:]
|
|
33
|
+
length = int(values[2])
|
|
34
|
+
if length < 0:
|
|
35
|
+
return text[start:length]
|
|
36
|
+
return text[start : start + length]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MergeOperator(SimpleOperator):
|
|
40
|
+
def compute(self, *values: Any) -> list[Any]:
|
|
41
|
+
merged: list[Any] = []
|
|
42
|
+
for value in values:
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
merged.extend(value)
|
|
45
|
+
else:
|
|
46
|
+
merged.append(value)
|
|
47
|
+
return merged
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MapOperator(Operator):
|
|
51
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> list[Any]:
|
|
52
|
+
collection = evaluator.apply(args[0], data)
|
|
53
|
+
if not isinstance(collection, list):
|
|
54
|
+
return []
|
|
55
|
+
return [evaluator.apply(args[1], item) for item in collection]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FilterOperator(Operator):
|
|
59
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> list[Any]:
|
|
60
|
+
collection = evaluator.apply(args[0], data)
|
|
61
|
+
if not isinstance(collection, list):
|
|
62
|
+
return []
|
|
63
|
+
return [item for item in collection if Coercion.is_truthy(evaluator.apply(args[1], item))]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReduceOperator(Operator):
|
|
67
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
68
|
+
collection = evaluator.apply(args[0], data)
|
|
69
|
+
accumulator = evaluator.apply(args[2], data) if len(args) > 2 else None
|
|
70
|
+
if not isinstance(collection, list):
|
|
71
|
+
return accumulator
|
|
72
|
+
for item in collection:
|
|
73
|
+
accumulator = evaluator.apply(args[1], {"current": item, "accumulator": accumulator})
|
|
74
|
+
return accumulator
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AllOperator(Operator):
|
|
78
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> bool:
|
|
79
|
+
collection = evaluator.apply(args[0], data)
|
|
80
|
+
if not isinstance(collection, list) or not collection:
|
|
81
|
+
return False
|
|
82
|
+
return all(Coercion.is_truthy(evaluator.apply(args[1], item)) for item in collection)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SomeOperator(Operator):
|
|
86
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> bool:
|
|
87
|
+
collection = evaluator.apply(args[0], data)
|
|
88
|
+
if not isinstance(collection, list):
|
|
89
|
+
return False
|
|
90
|
+
return any(Coercion.is_truthy(evaluator.apply(args[1], item)) for item in collection)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NoneMatchOperator(Operator):
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
self._some = SomeOperator()
|
|
96
|
+
|
|
97
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> bool:
|
|
98
|
+
return not self._some.apply(evaluator, args, data)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.coercion import Coercion
|
|
6
|
+
from fast_feature.engine.operator import SimpleOperator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EqualsOperator(SimpleOperator):
|
|
10
|
+
def compute(self, *values: Any) -> bool:
|
|
11
|
+
return Coercion.soft_equals(values[0], values[1])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotEqualsOperator(SimpleOperator):
|
|
15
|
+
def compute(self, *values: Any) -> bool:
|
|
16
|
+
return not Coercion.soft_equals(values[0], values[1])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StrictEqualsOperator(SimpleOperator):
|
|
20
|
+
def compute(self, *values: Any) -> bool:
|
|
21
|
+
return Coercion.hard_equals(values[0], values[1])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StrictNotEqualsOperator(SimpleOperator):
|
|
25
|
+
def compute(self, *values: Any) -> bool:
|
|
26
|
+
return not Coercion.hard_equals(values[0], values[1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LessThanOperator(SimpleOperator):
|
|
30
|
+
def compute(self, *values: Any) -> bool:
|
|
31
|
+
if len(values) == 3:
|
|
32
|
+
return Coercion.less_than(values[0], values[1]) and Coercion.less_than(
|
|
33
|
+
values[1], values[2]
|
|
34
|
+
)
|
|
35
|
+
return Coercion.less_than(values[0], values[1])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LessThanOrEqualOperator(SimpleOperator):
|
|
39
|
+
def compute(self, *values: Any) -> bool:
|
|
40
|
+
if len(values) == 3:
|
|
41
|
+
return Coercion.less_than_or_equal(
|
|
42
|
+
values[0], values[1]
|
|
43
|
+
) and Coercion.less_than_or_equal(values[1], values[2])
|
|
44
|
+
return Coercion.less_than_or_equal(values[0], values[1])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GreaterThanOperator(SimpleOperator):
|
|
48
|
+
def compute(self, *values: Any) -> bool:
|
|
49
|
+
return Coercion.less_than(values[1], values[0])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GreaterThanOrEqualOperator(SimpleOperator):
|
|
53
|
+
def compute(self, *values: Any) -> bool:
|
|
54
|
+
return Coercion.less_than_or_equal(values[1], values[0])
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.operator import Operator
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataPath:
|
|
12
|
+
"""Resolves a dotted variable path against an evaluation data object."""
|
|
13
|
+
|
|
14
|
+
_MISSING: Any = object()
|
|
15
|
+
|
|
16
|
+
def __init__(self, key: Any) -> None:
|
|
17
|
+
self._key = key
|
|
18
|
+
|
|
19
|
+
def resolve(self, data: Any, default: Any = None) -> Any:
|
|
20
|
+
found = self._lookup(data)
|
|
21
|
+
return default if found is self._MISSING else found
|
|
22
|
+
|
|
23
|
+
def is_present(self, data: Any) -> bool:
|
|
24
|
+
return self._lookup(data) not in (self._MISSING, None)
|
|
25
|
+
|
|
26
|
+
def _lookup(self, data: Any) -> Any:
|
|
27
|
+
if self._key is None or self._key == "":
|
|
28
|
+
return data
|
|
29
|
+
current = data
|
|
30
|
+
for part in str(self._key).split("."):
|
|
31
|
+
if isinstance(current, dict) and part in current:
|
|
32
|
+
current = current[part]
|
|
33
|
+
elif isinstance(current, list):
|
|
34
|
+
try:
|
|
35
|
+
current = current[int(part)]
|
|
36
|
+
except (ValueError, IndexError):
|
|
37
|
+
return self._MISSING
|
|
38
|
+
else:
|
|
39
|
+
return self._MISSING
|
|
40
|
+
return current
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VarOperator(Operator):
|
|
44
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
45
|
+
key = evaluator.apply(args[0], data) if args else ""
|
|
46
|
+
default = evaluator.apply(args[1], data) if len(args) > 1 else None
|
|
47
|
+
return DataPath(key).resolve(data, default)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MissingOperator(Operator):
|
|
51
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> list[Any]:
|
|
52
|
+
keys = [evaluator.apply(arg, data) for arg in args]
|
|
53
|
+
if len(keys) == 1 and isinstance(keys[0], list):
|
|
54
|
+
keys = keys[0]
|
|
55
|
+
return [key for key in keys if not DataPath(key).is_present(data)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MissingSomeOperator(Operator):
|
|
59
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> list[Any]:
|
|
60
|
+
minimum = evaluator.apply(args[0], data)
|
|
61
|
+
keys = evaluator.apply(args[1], data)
|
|
62
|
+
if not isinstance(keys, list):
|
|
63
|
+
return []
|
|
64
|
+
present = [key for key in keys if DataPath(key).is_present(data)]
|
|
65
|
+
if len(present) >= int(minimum):
|
|
66
|
+
return []
|
|
67
|
+
return [key for key in keys if not DataPath(key).is_present(data)]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.coercion import Coercion
|
|
6
|
+
from fast_feature.engine.operator import Operator, SimpleOperator
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IfOperator(Operator):
|
|
13
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
14
|
+
for index in range(0, len(args) - 1, 2):
|
|
15
|
+
if Coercion.is_truthy(evaluator.apply(args[index], data)):
|
|
16
|
+
return evaluator.apply(args[index + 1], data)
|
|
17
|
+
if len(args) % 2:
|
|
18
|
+
return evaluator.apply(args[-1], data)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AndOperator(Operator):
|
|
23
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
24
|
+
result: Any = True
|
|
25
|
+
for arg in args:
|
|
26
|
+
result = evaluator.apply(arg, data)
|
|
27
|
+
if not Coercion.is_truthy(result):
|
|
28
|
+
return result
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OrOperator(Operator):
|
|
33
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
34
|
+
result: Any = False
|
|
35
|
+
for arg in args:
|
|
36
|
+
result = evaluator.apply(arg, data)
|
|
37
|
+
if Coercion.is_truthy(result):
|
|
38
|
+
return result
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotOperator(SimpleOperator):
|
|
43
|
+
def compute(self, *values: Any) -> bool:
|
|
44
|
+
return not Coercion.is_truthy(values[0])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ToBoolOperator(SimpleOperator):
|
|
48
|
+
def compute(self, *values: Any) -> bool:
|
|
49
|
+
return Coercion.is_truthy(values[0])
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from fast_feature.engine.coercion import Coercion
|
|
6
|
+
from fast_feature.engine.hashing import Hasher
|
|
7
|
+
from fast_feature.engine.operator import Operator, SimpleOperator
|
|
8
|
+
from fast_feature.engine.semver import SemanticVersion
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fast_feature.engine.evaluator import JsonLogicEvaluator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StartsWithOperator(SimpleOperator):
|
|
15
|
+
def compute(self, *values: Any) -> bool:
|
|
16
|
+
value, prefix = values[0], values[1]
|
|
17
|
+
if not isinstance(value, str) or not isinstance(prefix, str):
|
|
18
|
+
return False
|
|
19
|
+
return value.startswith(prefix)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EndsWithOperator(SimpleOperator):
|
|
23
|
+
def compute(self, *values: Any) -> bool:
|
|
24
|
+
value, suffix = values[0], values[1]
|
|
25
|
+
if not isinstance(value, str) or not isinstance(suffix, str):
|
|
26
|
+
return False
|
|
27
|
+
return value.endswith(suffix)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SemVerOperator(SimpleOperator):
|
|
31
|
+
def compute(self, *values: Any) -> bool:
|
|
32
|
+
if len(values) != 3:
|
|
33
|
+
return False
|
|
34
|
+
left = SemanticVersion.parse(values[0])
|
|
35
|
+
right = SemanticVersion.parse(values[2])
|
|
36
|
+
if left is None or right is None:
|
|
37
|
+
return False
|
|
38
|
+
return left.satisfies(values[1], right)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FractionalOperator(Operator):
|
|
42
|
+
"""Deterministic percentage rollout, bucketed by a hash of the bucket key."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, hasher: Hasher) -> None:
|
|
45
|
+
self._hasher = hasher
|
|
46
|
+
|
|
47
|
+
def apply(self, evaluator: JsonLogicEvaluator, args: list[Any], data: Any) -> Any:
|
|
48
|
+
if not args:
|
|
49
|
+
return None
|
|
50
|
+
first = args[0] if isinstance(args[0], list) else evaluator.apply(args[0], data)
|
|
51
|
+
if isinstance(first, list):
|
|
52
|
+
bucket_key = self._default_bucket_key(data)
|
|
53
|
+
raw_buckets = args
|
|
54
|
+
else:
|
|
55
|
+
bucket_key = Coercion.to_str(first)
|
|
56
|
+
raw_buckets = args[1:]
|
|
57
|
+
|
|
58
|
+
names: list[str] = []
|
|
59
|
+
weights: list[float] = []
|
|
60
|
+
for bucket in raw_buckets:
|
|
61
|
+
evaluated = evaluator.apply(bucket, data)
|
|
62
|
+
if isinstance(evaluated, list) and evaluated:
|
|
63
|
+
names.append(Coercion.to_str(evaluated[0]))
|
|
64
|
+
weights.append(float(evaluated[1]) if len(evaluated) > 1 else 1.0)
|
|
65
|
+
|
|
66
|
+
total = sum(weights)
|
|
67
|
+
if not names or total <= 0:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
point = self._hasher.hash(bucket_key) / self._hasher.max_value * 100.0
|
|
71
|
+
cumulative = 0.0
|
|
72
|
+
for name, weight in zip(names, weights, strict=True):
|
|
73
|
+
cumulative += weight * 100.0 / total
|
|
74
|
+
if point < cumulative:
|
|
75
|
+
return name
|
|
76
|
+
return names[-1]
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _default_bucket_key(data: Any) -> str:
|
|
80
|
+
flag_key = ""
|
|
81
|
+
targeting_key = ""
|
|
82
|
+
if isinstance(data, dict):
|
|
83
|
+
meta = data.get("$flag")
|
|
84
|
+
if isinstance(meta, dict):
|
|
85
|
+
flag_key = Coercion.to_str(meta.get("key", ""))
|
|
86
|
+
targeting_key = Coercion.to_str(data.get("targetingKey", ""))
|
|
87
|
+
return f"{flag_key}{targeting_key}"
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fast_feature.engine.operator import Operator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OperatorRegistry:
|
|
7
|
+
"""A name-to-operator lookup used by the evaluator."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, operators: dict[str, Operator] | None = None) -> None:
|
|
10
|
+
self._operators: dict[str, Operator] = dict(operators or {})
|
|
11
|
+
|
|
12
|
+
def register(self, name: str, operator: Operator) -> None:
|
|
13
|
+
self._operators[name] = operator
|
|
14
|
+
|
|
15
|
+
def resolve(self, name: str) -> Operator | None:
|
|
16
|
+
return self._operators.get(name)
|
|
17
|
+
|
|
18
|
+
def extended_with(self, operators: dict[str, Operator]) -> OperatorRegistry:
|
|
19
|
+
"""Return a new registry with ``operators`` added on top of this one."""
|
|
20
|
+
merged = dict(self._operators)
|
|
21
|
+
merged.update(operators)
|
|
22
|
+
return OperatorRegistry(merged)
|
|
23
|
+
|
|
24
|
+
def __contains__(self, name: object) -> bool:
|
|
25
|
+
return name in self._operators
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, order=True)
|
|
9
|
+
class SemanticVersion:
|
|
10
|
+
"""A comparable ``major.minor.patch`` version."""
|
|
11
|
+
|
|
12
|
+
major: int
|
|
13
|
+
minor: int
|
|
14
|
+
patch: int
|
|
15
|
+
|
|
16
|
+
_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def parse(cls, value: Any) -> SemanticVersion | None:
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
return None
|
|
22
|
+
match = cls._PATTERN.match(value.strip())
|
|
23
|
+
if match is None:
|
|
24
|
+
return None
|
|
25
|
+
return cls(int(match.group(1)), int(match.group(2) or 0), int(match.group(3) or 0))
|
|
26
|
+
|
|
27
|
+
def satisfies(self, operator: Any, other: SemanticVersion) -> bool:
|
|
28
|
+
if operator in ("=", "=="):
|
|
29
|
+
return self == other
|
|
30
|
+
if operator == "!=":
|
|
31
|
+
return self != other
|
|
32
|
+
if operator == "<":
|
|
33
|
+
return self < other
|
|
34
|
+
if operator == "<=":
|
|
35
|
+
return self <= other
|
|
36
|
+
if operator == ">":
|
|
37
|
+
return self > other
|
|
38
|
+
if operator == ">=":
|
|
39
|
+
return self >= other
|
|
40
|
+
if operator == "^":
|
|
41
|
+
return self.major == other.major
|
|
42
|
+
if operator == "~":
|
|
43
|
+
return self.major == other.major and self.minor == other.minor
|
|
44
|
+
return False
|