fast-feature-engine 0.0.1__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.
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .coercion import Coercion
4
+
5
+ __all__ = ["Coercion"]
@@ -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,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import EngineError
4
+ from .json_logic import JsonLogicError
5
+
6
+ __all__ = ["EngineError", "JsonLogicError"]
@@ -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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import EngineError
4
+
5
+
6
+ class JsonLogicError(EngineError):
7
+ """Raised when a rule is malformed or cannot be evaluated."""
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .evaluator import JsonLogicEvaluator
4
+
5
+ __all__ = ["JsonLogicEvaluator"]
@@ -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,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .hasher import Hasher
4
+ from .murmur3_hasher import Murmur3Hasher
5
+
6
+ __all__ = ["Hasher", "Murmur3Hasher"]
@@ -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,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .operator import Operator
4
+ from .simple_operator import SimpleOperator
5
+
6
+ __all__ = ["Operator", "SimpleOperator"]
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .registry import OperatorRegistry
4
+
5
+ __all__ = ["OperatorRegistry"]
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .semantic_version import SemanticVersion
4
+
5
+ __all__ = ["SemanticVersion"]
@@ -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
@@ -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,30 @@
1
+ fast_feature/engine/__init__.py,sha256=uP4As5IN5kYR_DAaFheYRrpaDUH0Pk9aNAPsTObGzWQ,711
2
+ fast_feature/engine/coercion/__init__.py,sha256=8BBMjFABOzXiukScbGl1OTo1U-zPwwv8C2ZY6qEBECY,91
3
+ fast_feature/engine/coercion/coercion.py,sha256=_UK61kaNhD-UD-UvxR67pGnf5haS8RdbWKB32vwjNck,2348
4
+ fast_feature/engine/engine.py,sha256=oBTSrpZQjAyCRFRjf9pB7JqLqc02oEXzEpow43ekU9w,3149
5
+ fast_feature/engine/errors/__init__.py,sha256=vbUcD6zPwKcBVIbV3Omr_9Pkf2-fMWpVLiuhlwklTro,150
6
+ fast_feature/engine/errors/base.py,sha256=11CZuS6jFmGkH-W2TxwWxubhq6a1v2YRDGDj98z4tOQ,277
7
+ fast_feature/engine/errors/json_logic.py,sha256=mdGnZTXfCiQuykkkCVDcDx_BY_nQD9KyNewW4s0sZW8,169
8
+ fast_feature/engine/evaluator/__init__.py,sha256=rUcCigBv3h3FjtIFhP7OGd12tNDg9jyHtppXfHmIBaU,112
9
+ fast_feature/engine/evaluator/evaluator.py,sha256=D-oCBg8r6okkj1MLCVZWUC2cQ1NzqoVlcOh7bAqTvZg,1088
10
+ fast_feature/engine/hashing/__init__.py,sha256=-xuik0Hlb9E_XRLytIOkXUMLWzVY0iA5-NytJV8BM-g,144
11
+ fast_feature/engine/hashing/hasher.py,sha256=q8eL_egEgjWVxRBE74DeXjrJp_xp88sEHw7AgPGPTg4,367
12
+ fast_feature/engine/hashing/murmur3_hasher.py,sha256=gikZSQSV6oNqKpcrnGibXkzoWZqS72uq5kFQE0Ph1SU,301
13
+ fast_feature/engine/operator/__init__.py,sha256=iAdHgUzT5VLb9oxxrO9_cm4bzsesYvPZUsLhCP8h7xc,153
14
+ fast_feature/engine/operator/operator.py,sha256=HzzaL_Ku8AOVK7QpC4qICukvG_cPvPK-WVI17xfM7aM,571
15
+ fast_feature/engine/operator/simple_operator.py,sha256=mTpgsch_c4uUwcZRKY-F1ySVdhQxRKQGl3Uux1GrCTg,642
16
+ fast_feature/engine/operators/__init__.py,sha256=XxPpiKOzXseJdqsAfmY2OgGYLX-VXSTiQAXjBHfKamA,2907
17
+ fast_feature/engine/operators/arithmetic.py,sha256=bXabg__o6WobFNg207aAvfaNeeo-xeFnH57JXHeIyqU,1691
18
+ fast_feature/engine/operators/collection.py,sha256=ygdDs4FhgWdJn6j-zdRGyPqePnRfBVgNcKfyclD1ttE,3487
19
+ fast_feature/engine/operators/comparison.py,sha256=ojLVy-Kb0NP930QDdpaIjTFpc9AukqXEH4ZGEOl-UEQ,1720
20
+ fast_feature/engine/operators/data.py,sha256=znfkMBOC5oCI5lz9IMYrqOpulRqfxgKrBY8lP25bYLk,2391
21
+ fast_feature/engine/operators/logic.py,sha256=-SDDv1ioNgS1HJ2rt8hJqSTKMNRDrlK1NnDWFLIIY8c,1562
22
+ fast_feature/engine/operators/targeting.py,sha256=jMOA0N5SADPLlqOyFGQV9ORtLx89JGSUYJhGQbYkHxU,3060
23
+ fast_feature/engine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ fast_feature/engine/registry/__init__.py,sha256=-Mz4xUh0X_TmG0_2Al-frHuGDT6VToLc43OXh2wQhdU,107
25
+ fast_feature/engine/registry/registry.py,sha256=SsxErWpSWM03pGBoINXKYH5TisHpJHN0zBwDZqRFkxw,884
26
+ fast_feature/engine/semver/__init__.py,sha256=VjS1plLsp-4P2KWs9_3B3mNVPRshbWIGabC9u2KwHGw,113
27
+ fast_feature/engine/semver/semantic_version.py,sha256=CKIKkYnR8OYnf5pWK6m61satn1kNAyM6r5A3DIuHUrA,1341
28
+ fast_feature_engine-0.0.1.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
29
+ fast_feature_engine-0.0.1.dist-info/METADATA,sha256=LomDHOVg-v7q2rnIVF5aaY4ai9v8fWXZ1EgK17qLINc,741
30
+ fast_feature_engine-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any