flagsmith-flag-engine 6.0.2__tar.gz → 7.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {flagsmith_flag_engine-6.0.2/flagsmith_flag_engine.egg-info → flagsmith_flag_engine-7.0.0}/PKG-INFO +8 -6
  2. flagsmith_flag_engine-7.0.0/flag_engine/context/mappers.py +38 -0
  3. flagsmith_flag_engine-7.0.0/flag_engine/context/types.py +72 -0
  4. flagsmith_flag_engine-7.0.0/flag_engine/engine.py +11 -0
  5. flagsmith_flag_engine-7.0.0/flag_engine/result/types.py +30 -0
  6. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/segments/constants.py +3 -0
  7. flagsmith_flag_engine-7.0.0/flag_engine/segments/evaluator.py +374 -0
  8. flagsmith_flag_engine-7.0.0/flag_engine/segments/types.py +38 -0
  9. flagsmith_flag_engine-7.0.0/flag_engine/segments/utils.py +24 -0
  10. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/utils/types.py +1 -1
  11. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0/flagsmith_flag_engine.egg-info}/PKG-INFO +8 -6
  12. flagsmith_flag_engine-7.0.0/flagsmith_flag_engine.egg-info/SOURCES.txt +29 -0
  13. flagsmith_flag_engine-7.0.0/flagsmith_flag_engine.egg-info/requires.txt +3 -0
  14. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/setup.py +8 -6
  15. flagsmith_flag_engine-6.0.2/flag_engine/context/mappers.py +0 -39
  16. flagsmith_flag_engine-6.0.2/flag_engine/context/types.py +0 -28
  17. flagsmith_flag_engine-6.0.2/flag_engine/engine.py +0 -146
  18. flagsmith_flag_engine-6.0.2/flag_engine/environments/integrations/models.py +0 -9
  19. flagsmith_flag_engine-6.0.2/flag_engine/environments/models.py +0 -95
  20. flagsmith_flag_engine-6.0.2/flag_engine/features/constants.py +0 -3
  21. flagsmith_flag_engine-6.0.2/flag_engine/features/models.py +0 -159
  22. flagsmith_flag_engine-6.0.2/flag_engine/identities/models.py +0 -93
  23. flagsmith_flag_engine-6.0.2/flag_engine/identities/traits/constants.py +0 -1
  24. flagsmith_flag_engine-6.0.2/flag_engine/identities/traits/models.py +0 -8
  25. flagsmith_flag_engine-6.0.2/flag_engine/identities/traits/types.py +0 -62
  26. flagsmith_flag_engine-6.0.2/flag_engine/organisations/__init__.py +0 -0
  27. flagsmith_flag_engine-6.0.2/flag_engine/organisations/models.py +0 -13
  28. flagsmith_flag_engine-6.0.2/flag_engine/projects/__init__.py +0 -0
  29. flagsmith_flag_engine-6.0.2/flag_engine/projects/models.py +0 -16
  30. flagsmith_flag_engine-6.0.2/flag_engine/segments/__init__.py +0 -0
  31. flagsmith_flag_engine-6.0.2/flag_engine/segments/evaluator.py +0 -255
  32. flagsmith_flag_engine-6.0.2/flag_engine/segments/models.py +0 -41
  33. flagsmith_flag_engine-6.0.2/flag_engine/segments/types.py +0 -24
  34. flagsmith_flag_engine-6.0.2/flag_engine/types/__init__.py +0 -0
  35. flagsmith_flag_engine-6.0.2/flag_engine/utils/__init__.py +0 -0
  36. flagsmith_flag_engine-6.0.2/flag_engine/utils/datetime.py +0 -5
  37. flagsmith_flag_engine-6.0.2/flag_engine/utils/exceptions.py +0 -10
  38. flagsmith_flag_engine-6.0.2/flag_engine/utils/json/__init__.py +0 -0
  39. flagsmith_flag_engine-6.0.2/flagsmith_flag_engine.egg-info/SOURCES.txt +0 -46
  40. flagsmith_flag_engine-6.0.2/flagsmith_flag_engine.egg-info/requires.txt +0 -3
  41. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/LICENSE.txt +0 -0
  42. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/README.md +0 -0
  43. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/__init__.py +0 -0
  44. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/context/__init__.py +0 -0
  45. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/py.typed +0 -0
  46. {flagsmith_flag_engine-6.0.2/flag_engine/environments → flagsmith_flag_engine-7.0.0/flag_engine/result}/__init__.py +0 -0
  47. {flagsmith_flag_engine-6.0.2/flag_engine/environments/integrations → flagsmith_flag_engine-7.0.0/flag_engine/segments}/__init__.py +0 -0
  48. {flagsmith_flag_engine-6.0.2/flag_engine/features → flagsmith_flag_engine-7.0.0/flag_engine/types}/__init__.py +0 -0
  49. {flagsmith_flag_engine-6.0.2/flag_engine/identities → flagsmith_flag_engine-7.0.0/flag_engine/utils}/__init__.py +0 -0
  50. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/utils/hashing.py +0 -0
  51. {flagsmith_flag_engine-6.0.2/flag_engine/identities/traits → flagsmith_flag_engine-7.0.0/flag_engine/utils/json}/__init__.py +0 -0
  52. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/utils/json/encoders.py +0 -0
  53. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flag_engine/utils/semver.py +0 -0
  54. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flagsmith_flag_engine.egg-info/dependency_links.txt +0 -0
  55. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/flagsmith_flag_engine.egg-info/top_level.txt +0 -0
  56. {flagsmith_flag_engine-6.0.2 → flagsmith_flag_engine-7.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-flag-engine
3
- Version: 6.0.2
3
+ Version: 7.0.0
4
4
  Summary: Flag engine for the Flagsmith API.
5
5
  Home-page: https://github.com/Flagsmith/flagsmith-engine
6
6
  Author: Flagsmith
@@ -8,14 +8,16 @@ Author-email: support@flagsmith.com
8
8
  License: BSD3
9
9
  Classifier: License :: OSI Approved :: BSD License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.7
12
- Classifier: Programming Language :: Python :: 3.8
13
11
  Classifier: Programming Language :: Python :: 3.9
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
14
16
  Description-Content-Type: text/markdown
15
17
  License-File: LICENSE.txt
16
- Requires-Dist: pydantic<3,>=2.3.0
17
- Requires-Dist: pydantic-collections<1,>=0.5.1
18
- Requires-Dist: semver>=3.0.1
18
+ Requires-Dist: jsonpath-rfc9535<1,>=0.1.5
19
+ Requires-Dist: semver<4,>=3.0.4
20
+ Requires-Dist: typing-extensions<5,>=4.14.1
19
21
  Dynamic: author
20
22
  Dynamic: author-email
21
23
  Dynamic: classifier
@@ -0,0 +1,38 @@
1
+ import re
2
+ import typing
3
+ from decimal import Decimal
4
+
5
+ from flag_engine.segments.types import ContextValue, is_context_value
6
+
7
+
8
+ def map_any_value_to_context_value(value: typing.Any) -> ContextValue:
9
+ """
10
+ Try to coerce a value of arbitrary type to a context value type.
11
+ Union member-specific constraints, such as max string value length, are ignored here.
12
+ Replicate behaviour from marshmallow/pydantic V1 for number-like strings.
13
+ For decimals return an int in case of unset exponent.
14
+ When in doubt, return string.
15
+
16
+ Can be used as a `pydantic.BeforeValidator`.
17
+ """
18
+ if is_context_value(value):
19
+ if isinstance(value, str):
20
+ return _map_string_value_to_context_value(value)
21
+ return value
22
+ if isinstance(value, Decimal):
23
+ if value.as_tuple().exponent:
24
+ return float(str(value))
25
+ return int(value)
26
+ return str(value)
27
+
28
+
29
+ _int_pattern = re.compile(r"-?[0-9]+")
30
+ _float_pattern = re.compile(r"-?[0-9]+\.[0-9]+")
31
+
32
+
33
+ def _map_string_value_to_context_value(value: str) -> ContextValue:
34
+ if _int_pattern.fullmatch(value):
35
+ return int(value)
36
+ if _float_pattern.fullmatch(value):
37
+ return float(value)
38
+ return value
@@ -0,0 +1,72 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json # noqa: E501
3
+ # timestamp: 2025-08-25T11:10:31+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
8
+
9
+ from typing_extensions import NotRequired
10
+
11
+ from flag_engine.segments.types import ConditionOperator, ContextValue, RuleType
12
+
13
+
14
+ class EnvironmentContext(TypedDict):
15
+ key: str
16
+ name: str
17
+
18
+
19
+ class FeatureValue(TypedDict):
20
+ value: Any
21
+ weight: float
22
+
23
+
24
+ class IdentityContext(TypedDict):
25
+ identifier: str
26
+ key: str
27
+ traits: NotRequired[Dict[str, ContextValue]]
28
+
29
+
30
+ class StrValueSegmentCondition(TypedDict):
31
+ property: str
32
+ operator: ConditionOperator
33
+ value: str
34
+
35
+
36
+ class InOperatorSegmentCondition(TypedDict):
37
+ property: str
38
+ operator: Literal["IN"]
39
+ value: List[str]
40
+
41
+
42
+ SegmentCondition = Union[StrValueSegmentCondition, InOperatorSegmentCondition]
43
+
44
+
45
+ class SegmentRule(TypedDict):
46
+ type: RuleType
47
+ conditions: NotRequired[List[SegmentCondition]]
48
+ rules: NotRequired[List[SegmentRule]]
49
+
50
+
51
+ class FeatureContext(TypedDict):
52
+ key: str
53
+ feature_key: str
54
+ name: str
55
+ enabled: bool
56
+ value: Any
57
+ variants: NotRequired[List[FeatureValue]]
58
+ priority: NotRequired[float]
59
+
60
+
61
+ class SegmentContext(TypedDict):
62
+ key: str
63
+ name: str
64
+ rules: List[SegmentRule]
65
+ overrides: NotRequired[List[FeatureContext]]
66
+
67
+
68
+ class EvaluationContext(TypedDict):
69
+ environment: EnvironmentContext
70
+ identity: NotRequired[Optional[IdentityContext]]
71
+ segments: NotRequired[Dict[str, SegmentContext]]
72
+ features: NotRequired[Dict[str, FeatureContext]]
@@ -0,0 +1,11 @@
1
+ from flag_engine.context.types import EvaluationContext
2
+ from flag_engine.result.types import EvaluationResult
3
+ from flag_engine.segments.evaluator import get_evaluation_result
4
+ from flag_engine.segments.types import ContextValue
5
+
6
+ __all__ = (
7
+ "ContextValue",
8
+ "EvaluationContext",
9
+ "EvaluationResult",
10
+ "get_evaluation_result",
11
+ )
@@ -0,0 +1,30 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: result.json
3
+ # timestamp: 2025-08-11T11:47:46+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, List, Optional, TypedDict
8
+
9
+ from typing_extensions import NotRequired
10
+
11
+ from flag_engine.context.types import EvaluationContext
12
+
13
+
14
+ class FlagResult(TypedDict):
15
+ name: str
16
+ feature_key: str
17
+ enabled: bool
18
+ value: NotRequired[Optional[Any]]
19
+ reason: NotRequired[str]
20
+
21
+
22
+ class SegmentResult(TypedDict):
23
+ key: str
24
+ name: str
25
+
26
+
27
+ class EvaluationResult(TypedDict):
28
+ context: EvaluationContext
29
+ flags: List[FlagResult]
30
+ segments: List[SegmentResult]
@@ -20,3 +20,6 @@ MODULO: ConditionOperator = "MODULO"
20
20
  IS_SET: ConditionOperator = "IS_SET"
21
21
  IS_NOT_SET: ConditionOperator = "IS_NOT_SET"
22
22
  IN: ConditionOperator = "IN"
23
+
24
+ # Lowest possible priority for segment overrides
25
+ DEFAULT_PRIORITY = float("inf")
@@ -0,0 +1,374 @@
1
+ import json
2
+ import operator
3
+ import re
4
+ import typing
5
+ import warnings
6
+ from contextlib import suppress
7
+ from functools import lru_cache, wraps
8
+
9
+ import jsonpath_rfc9535
10
+ import semver
11
+
12
+ from flag_engine.context.mappers import map_any_value_to_context_value
13
+ from flag_engine.context.types import (
14
+ EvaluationContext,
15
+ FeatureContext,
16
+ SegmentCondition,
17
+ SegmentContext,
18
+ SegmentRule,
19
+ StrValueSegmentCondition,
20
+ )
21
+ from flag_engine.result.types import EvaluationResult, FlagResult, SegmentResult
22
+ from flag_engine.segments import constants
23
+ from flag_engine.segments.types import ConditionOperator, ContextValue, is_context_value
24
+ from flag_engine.segments.utils import escape_double_quotes, get_matching_function
25
+ from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids
26
+ from flag_engine.utils.semver import is_semver
27
+ from flag_engine.utils.types import SupportsStr, get_casting_function
28
+
29
+
30
+ def get_evaluation_result(context: EvaluationContext) -> EvaluationResult:
31
+ """
32
+ Get the evaluation result for a given context.
33
+
34
+ :param context: the evaluation context
35
+ :return: EvaluationResult containing the context, flags, and segments
36
+ """
37
+ segments: list[SegmentResult] = []
38
+ segment_feature_contexts: dict[SupportsStr, FeatureContext] = {}
39
+ for segment_context in (context.get("segments") or {}).values():
40
+ if not is_context_in_segment(context, segment_context):
41
+ continue
42
+
43
+ segments.append(
44
+ {
45
+ "key": segment_context["key"],
46
+ "name": segment_context["name"],
47
+ }
48
+ )
49
+
50
+ if overrides := segment_context.get("overrides"):
51
+ for override_feature_context in overrides:
52
+ feature_key = override_feature_context["feature_key"]
53
+ if (
54
+ feature_key not in segment_feature_contexts
55
+ or override_feature_context.get(
56
+ "priority",
57
+ constants.DEFAULT_PRIORITY,
58
+ )
59
+ < segment_feature_contexts[feature_key].get(
60
+ "priority",
61
+ constants.DEFAULT_PRIORITY,
62
+ )
63
+ ):
64
+ segment_feature_contexts[feature_key] = override_feature_context
65
+
66
+ identity_key = get_context_value(context, "$.identity.key")
67
+ flags: list[FlagResult] = [
68
+ (
69
+ {
70
+ "enabled": segment_feature_context["enabled"],
71
+ "feature_key": segment_feature_context["feature_key"],
72
+ "name": segment_feature_context["name"],
73
+ "reason": f"TARGETING_MATCH; segment={segment_context['name']}",
74
+ "value": segment_feature_context.get("value"),
75
+ }
76
+ if (
77
+ segment_feature_context := segment_feature_contexts.get(
78
+ feature_context["feature_key"],
79
+ )
80
+ )
81
+ else get_flag_result_from_feature_context(
82
+ feature_context=feature_context,
83
+ key=identity_key,
84
+ )
85
+ )
86
+ for feature_context in (context.get("features") or {}).values()
87
+ ]
88
+
89
+ return {
90
+ "context": context,
91
+ "flags": flags,
92
+ "segments": segments,
93
+ }
94
+
95
+
96
+ def get_flag_result_from_feature_context(
97
+ feature_context: FeatureContext,
98
+ key: typing.Optional[SupportsStr],
99
+ ) -> FlagResult:
100
+ """
101
+ Get a feature value from the feature context
102
+ for a given key.
103
+
104
+ :param feature_context: the feature context
105
+ :param key: the key to get the value for
106
+ :return: the value for the key in the feature context
107
+ """
108
+ if key is not None and (variants := feature_context.get("variants")):
109
+ percentage_value = get_hashed_percentage_for_object_ids(
110
+ [feature_context["key"], key]
111
+ )
112
+
113
+ # We expect `variants` to be pre-sorted in order of persistence. This gives us a
114
+ # way to ensure that the same value is returned every time we use the same
115
+ # percentage value.
116
+ start_percentage = 0.0
117
+
118
+ for variant in variants:
119
+ limit = (weight := variant["weight"]) + start_percentage
120
+ if start_percentage <= percentage_value < limit:
121
+ return {
122
+ "enabled": feature_context["enabled"],
123
+ "feature_key": feature_context["feature_key"],
124
+ "name": feature_context["name"],
125
+ "reason": f"SPLIT; weight={weight}",
126
+ "value": variant["value"],
127
+ }
128
+
129
+ start_percentage = limit
130
+
131
+ return {
132
+ "enabled": feature_context["enabled"],
133
+ "feature_key": feature_context["feature_key"],
134
+ "name": feature_context["name"],
135
+ "reason": "DEFAULT",
136
+ "value": feature_context["value"],
137
+ }
138
+
139
+
140
+ def is_context_in_segment(
141
+ context: EvaluationContext,
142
+ segment_context: SegmentContext,
143
+ ) -> bool:
144
+ return bool(rules := segment_context["rules"]) and all(
145
+ context_matches_rule(
146
+ context=context, rule=rule, segment_key=segment_context["key"]
147
+ )
148
+ for rule in rules
149
+ )
150
+
151
+
152
+ def context_matches_rule(
153
+ context: EvaluationContext,
154
+ rule: SegmentRule,
155
+ segment_key: SupportsStr,
156
+ ) -> bool:
157
+ matches_conditions = (
158
+ get_matching_function(rule["type"])(
159
+ [
160
+ context_matches_condition(
161
+ context=context,
162
+ condition=condition,
163
+ segment_key=segment_key,
164
+ )
165
+ for condition in conditions
166
+ ]
167
+ )
168
+ if (conditions := rule.get("conditions"))
169
+ else True
170
+ )
171
+
172
+ return matches_conditions and all(
173
+ context_matches_rule(
174
+ context=context,
175
+ rule=rule,
176
+ segment_key=segment_key,
177
+ )
178
+ for rule in rule.get("rules") or []
179
+ )
180
+
181
+
182
+ def context_matches_condition(
183
+ context: EvaluationContext,
184
+ condition: SegmentCondition,
185
+ segment_key: SupportsStr,
186
+ ) -> bool:
187
+ context_value = (
188
+ get_context_value(context, condition_property)
189
+ if (condition_property := condition.get("property"))
190
+ else None
191
+ )
192
+
193
+ if condition["operator"] == constants.IN:
194
+ if isinstance(segment_value := condition["value"], list):
195
+ in_values = segment_value
196
+ else:
197
+ try:
198
+ in_values = json.loads(segment_value)
199
+ # Only accept JSON lists.
200
+ # Ideally, we should use something like pydantic.TypeAdapter[list[str]],
201
+ # but we aim to ditch the pydantic dependency in the future.
202
+ if not isinstance(in_values, list):
203
+ raise ValueError
204
+ except ValueError:
205
+ in_values = segment_value.split(",")
206
+ in_values = [str(value) for value in in_values]
207
+ # Guard against comparing boolean values to numeric strings.
208
+ if isinstance(context_value, int) and not (
209
+ context_value is True or context_value is False
210
+ ):
211
+ context_value = str(context_value)
212
+ return context_value in in_values
213
+
214
+ condition = typing.cast(StrValueSegmentCondition, condition)
215
+
216
+ if condition["operator"] == constants.PERCENTAGE_SPLIT:
217
+ if context_value is not None:
218
+ object_ids = [segment_key, context_value]
219
+ else:
220
+ object_ids = [segment_key, get_context_value(context, "$.identity.key")]
221
+
222
+ float_value = float(condition["value"])
223
+ return get_hashed_percentage_for_object_ids(object_ids) <= float_value
224
+
225
+ if condition["operator"] == constants.IS_NOT_SET:
226
+ return context_value is None
227
+
228
+ if condition["operator"] == constants.IS_SET:
229
+ return context_value is not None
230
+
231
+ return (
232
+ _matches_context_value(condition, context_value)
233
+ if context_value is not None
234
+ else False
235
+ )
236
+
237
+
238
+ def get_context_value(
239
+ context: EvaluationContext,
240
+ property: str,
241
+ ) -> ContextValue:
242
+ value = None
243
+ if property.startswith("$."):
244
+ value = _get_context_value_getter(property)(context)
245
+ elif identity_context := context.get("identity"):
246
+ if traits := identity_context.get("traits"):
247
+ value = traits.get(property)
248
+ return map_any_value_to_context_value(value)
249
+
250
+
251
+ def _matches_context_value(
252
+ condition: StrValueSegmentCondition,
253
+ context_value: ContextValue,
254
+ ) -> bool:
255
+ if matcher := MATCHERS_BY_OPERATOR.get(condition["operator"]):
256
+ return matcher(condition["value"], context_value)
257
+
258
+ return False
259
+
260
+
261
+ def _evaluate_not_contains(
262
+ segment_value: typing.Optional[str],
263
+ context_value: ContextValue,
264
+ ) -> bool:
265
+ return isinstance(context_value, str) and str(segment_value) not in context_value
266
+
267
+
268
+ def _evaluate_regex(
269
+ segment_value: typing.Optional[str],
270
+ context_value: ContextValue,
271
+ ) -> bool:
272
+ return (
273
+ context_value is not None
274
+ and re.compile(str(segment_value)).match(str(context_value)) is not None
275
+ )
276
+
277
+
278
+ def _evaluate_modulo(
279
+ segment_value: typing.Optional[str],
280
+ context_value: ContextValue,
281
+ ) -> bool:
282
+ if not isinstance(context_value, (int, float)):
283
+ return False
284
+
285
+ if not segment_value:
286
+ return False
287
+
288
+ try:
289
+ divisor_part, remainder_part = segment_value.split("|")
290
+ divisor = float(divisor_part)
291
+ remainder = float(remainder_part)
292
+ except ValueError:
293
+ return False
294
+
295
+ return context_value % divisor == remainder
296
+
297
+
298
+ def _context_value_typed(
299
+ func: typing.Callable[..., bool],
300
+ ) -> typing.Callable[[typing.Optional[str], ContextValue], bool]:
301
+ @wraps(func)
302
+ def inner(
303
+ segment_value: typing.Optional[str],
304
+ context_value: typing.Union[ContextValue, semver.Version],
305
+ ) -> bool:
306
+ with suppress(TypeError, ValueError):
307
+ if isinstance(context_value, str) and is_semver(segment_value):
308
+ context_value = semver.Version.parse(
309
+ context_value,
310
+ )
311
+ match_value = get_casting_function(context_value)(segment_value)
312
+ return func(context_value, match_value)
313
+ return False
314
+
315
+ return inner
316
+
317
+
318
+ MATCHERS_BY_OPERATOR: dict[
319
+ ConditionOperator, typing.Callable[[typing.Optional[str], ContextValue], bool]
320
+ ] = {
321
+ constants.NOT_CONTAINS: _evaluate_not_contains,
322
+ constants.REGEX: _evaluate_regex,
323
+ constants.MODULO: _evaluate_modulo,
324
+ constants.EQUAL: _context_value_typed(operator.eq),
325
+ constants.GREATER_THAN: _context_value_typed(operator.gt),
326
+ constants.GREATER_THAN_INCLUSIVE: _context_value_typed(operator.ge),
327
+ constants.LESS_THAN: _context_value_typed(operator.lt),
328
+ constants.LESS_THAN_INCLUSIVE: _context_value_typed(operator.le),
329
+ constants.NOT_EQUAL: _context_value_typed(operator.ne),
330
+ constants.CONTAINS: _context_value_typed(operator.contains),
331
+ }
332
+
333
+
334
+ @lru_cache
335
+ def _get_context_value_getter(
336
+ property: str,
337
+ ) -> typing.Callable[[EvaluationContext], ContextValue]:
338
+ """
339
+ Get a function to retrieve a context value based on property value,
340
+ assumed to be either a JSONPath string or a trait key.
341
+
342
+ :param property: The property to retrieve the value for.
343
+ :return: A function that takes an EvaluationContext and returns the value.
344
+ """
345
+ try:
346
+ compiled_query = jsonpath_rfc9535.compile(property)
347
+ except jsonpath_rfc9535.JSONPathSyntaxError:
348
+ # This covers a rare case when a trait starting with "$.",
349
+ # but not a valid JSONPath, is used.
350
+ compiled_query = jsonpath_rfc9535.compile(
351
+ f'$.identity.traits["{escape_double_quotes(property)}"]',
352
+ )
353
+
354
+ def getter(context: EvaluationContext) -> ContextValue:
355
+ if typing.TYPE_CHECKING: # pragma: no cover
356
+ # Ugly hack to satisfy mypy :(
357
+ data = dict(context)
358
+ else:
359
+ data = context
360
+ try:
361
+ if result := compiled_query.find_one(data):
362
+ if is_context_value(value := result.value):
363
+ return value
364
+ return None
365
+ except jsonpath_rfc9535.JSONPathError: # pragma: no cover
366
+ # This is supposed to be unreachable, but if it happens,
367
+ # we log a warning and return None.
368
+ warnings.warn(
369
+ f"Failed to evaluate JSONPath query '{property}' in context: {context}",
370
+ RuntimeWarning,
371
+ )
372
+ return None
373
+
374
+ return getter
@@ -0,0 +1,38 @@
1
+ from typing import Any, Literal, Union, get_args
2
+
3
+ from typing_extensions import TypeGuard
4
+
5
+ ConditionOperator = Literal[
6
+ "EQUAL",
7
+ "GREATER_THAN",
8
+ "LESS_THAN",
9
+ "LESS_THAN_INCLUSIVE",
10
+ "CONTAINS",
11
+ "GREATER_THAN_INCLUSIVE",
12
+ "NOT_CONTAINS",
13
+ "NOT_EQUAL",
14
+ "REGEX",
15
+ "PERCENTAGE_SPLIT",
16
+ "MODULO",
17
+ "IS_SET",
18
+ "IS_NOT_SET",
19
+ "IN",
20
+ ]
21
+
22
+ RuleType = Literal[
23
+ "ALL",
24
+ "ANY",
25
+ "NONE",
26
+ ]
27
+
28
+
29
+ ContextValue = Union[None, int, float, bool, str]
30
+ _context_value_types = get_args(ContextValue)
31
+
32
+
33
+ def is_context_value(value: Any) -> TypeGuard[ContextValue]:
34
+ """
35
+ Check if the value is a valid context value type.
36
+ This function is used to determine if a value can be treated as a context value.
37
+ """
38
+ return isinstance(value, _context_value_types)
@@ -0,0 +1,24 @@
1
+ import typing
2
+
3
+ from flag_engine.segments.types import RuleType
4
+
5
+
6
+ def get_matching_function(
7
+ segment_type: RuleType,
8
+ ) -> typing.Callable[[typing.Iterable[object]], bool]:
9
+ return {
10
+ "ANY": any,
11
+ "ALL": all,
12
+ "NONE": none,
13
+ }[segment_type]
14
+
15
+
16
+ def none(iterable: typing.Iterable[object]) -> bool:
17
+ return not any(iterable)
18
+
19
+
20
+ def escape_double_quotes(value: str) -> str:
21
+ """
22
+ Escape double quotes in a string for JSONPath compatibility.
23
+ """
24
+ return value.replace('"', '\\"')
@@ -3,7 +3,7 @@ from functools import singledispatch
3
3
 
4
4
  import semver
5
5
 
6
- from flag_engine.identities.traits.types import ContextValue
6
+ from flag_engine.segments.types import ContextValue
7
7
  from flag_engine.utils.semver import remove_semver_suffix
8
8
 
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-flag-engine
3
- Version: 6.0.2
3
+ Version: 7.0.0
4
4
  Summary: Flag engine for the Flagsmith API.
5
5
  Home-page: https://github.com/Flagsmith/flagsmith-engine
6
6
  Author: Flagsmith
@@ -8,14 +8,16 @@ Author-email: support@flagsmith.com
8
8
  License: BSD3
9
9
  Classifier: License :: OSI Approved :: BSD License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.7
12
- Classifier: Programming Language :: Python :: 3.8
13
11
  Classifier: Programming Language :: Python :: 3.9
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
14
16
  Description-Content-Type: text/markdown
15
17
  License-File: LICENSE.txt
16
- Requires-Dist: pydantic<3,>=2.3.0
17
- Requires-Dist: pydantic-collections<1,>=0.5.1
18
- Requires-Dist: semver>=3.0.1
18
+ Requires-Dist: jsonpath-rfc9535<1,>=0.1.5
19
+ Requires-Dist: semver<4,>=3.0.4
20
+ Requires-Dist: typing-extensions<5,>=4.14.1
19
21
  Dynamic: author
20
22
  Dynamic: author-email
21
23
  Dynamic: classifier