flagsmith-flag-engine 10.2.0__tar.gz → 11.1.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 (29) hide show
  1. {flagsmith_flag_engine-10.2.0/flagsmith_flag_engine.egg-info → flagsmith_flag_engine-11.1.0}/PKG-INFO +4 -2
  2. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/context/types.py +2 -0
  3. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/segments/evaluator.py +201 -22
  4. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0/flagsmith_flag_engine.egg-info}/PKG-INFO +4 -2
  5. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/setup.py +3 -2
  6. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/LICENSE.txt +0 -0
  7. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/README.md +0 -0
  8. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/__init__.py +0 -0
  9. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/context/__init__.py +0 -0
  10. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/context/mappers.py +0 -0
  11. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/engine.py +0 -0
  12. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/py.typed +0 -0
  13. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/result/__init__.py +0 -0
  14. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/result/types.py +0 -0
  15. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/segments/__init__.py +0 -0
  16. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/segments/constants.py +0 -0
  17. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/segments/types.py +0 -0
  18. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/segments/utils.py +0 -0
  19. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/__init__.py +0 -0
  20. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/hashing.py +0 -0
  21. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/json/__init__.py +0 -0
  22. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/json/encoders.py +0 -0
  23. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/semver.py +0 -0
  24. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flag_engine/utils/types.py +0 -0
  25. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flagsmith_flag_engine.egg-info/SOURCES.txt +0 -0
  26. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flagsmith_flag_engine.egg-info/dependency_links.txt +0 -0
  27. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flagsmith_flag_engine.egg-info/requires.txt +0 -0
  28. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/flagsmith_flag_engine.egg-info/top_level.txt +0 -0
  29. {flagsmith_flag_engine-10.2.0 → flagsmith_flag_engine-11.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-flag-engine
3
- Version: 10.2.0
3
+ Version: 11.1.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,11 +8,12 @@ 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.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.10
16
17
  Description-Content-Type: text/markdown
17
18
  License-File: LICENSE.txt
18
19
  Requires-Dist: jsonpath-rfc9535<1,>=0.1.5
@@ -27,6 +28,7 @@ Dynamic: home-page
27
28
  Dynamic: license
28
29
  Dynamic: license-file
29
30
  Dynamic: requires-dist
31
+ Dynamic: requires-python
30
32
  Dynamic: summary
31
33
 
32
34
  [![Feature Flag, Remote Config and A/B Testing platform, Flagsmith](https://github.com/Flagsmith/flagsmith/raw/main/static-files/hero.png)](https://www.flagsmith.com/)
@@ -8,6 +8,7 @@ from typing import Any, Dict, Generic, List, Literal, Optional, Union
8
8
 
9
9
  from typing_extensions import NotRequired, TypedDict
10
10
 
11
+ from flag_engine.result.types import FlagResult
11
12
  from flag_engine.segments.types import (
12
13
  ConditionOperator,
13
14
  ContextValue,
@@ -79,3 +80,4 @@ class EvaluationContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]):
79
80
  identity: NotRequired[Optional[IdentityContext]]
80
81
  segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT, FeatureMetadataT]]]
81
82
  features: NotRequired[Dict[str, FeatureContext[FeatureMetadataT]]]
83
+ flags: NotRequired[Dict[str, FlagResult[FeatureMetadataT]]]
@@ -6,7 +6,7 @@ import re
6
6
  import typing
7
7
  import warnings
8
8
  from contextlib import suppress
9
- from functools import lru_cache, partial, wraps
9
+ from functools import cached_property, lru_cache, partial, wraps
10
10
 
11
11
  import jsonpath_rfc9535
12
12
  import semver
@@ -58,15 +58,66 @@ def get_evaluation_result(
58
58
  :return: EvaluationResult containing the context, flags, and segments
59
59
  """
60
60
  context = get_enriched_context(context)
61
- segments, segment_overrides = evaluate_segments(context)
61
+
62
+ resolved: _LazyFlags = _LazyFlags()
63
+ context = {**context, "flags": resolved}
64
+ resolved.bind(context)
65
+
66
+ segments, segment_overrides = evaluate_segments(context, resolved)
62
67
  flags = evaluate_features(context, segment_overrides)
63
68
 
69
+ if resolved.used:
70
+ # Only reached when a segment condition read a flag. Those results take
71
+ # precedence: they were resolved with the cycle guard, unlike the
72
+ # single-pass recomputation above.
73
+ flags.update(resolved)
74
+ for feature_name in resolved.cyclic:
75
+ if (flag := flags.get(feature_name)) is not None:
76
+ flag["reason"] = CIRCULAR_DEPENDENCY_REASON
77
+
64
78
  return {
65
79
  "flags": flags,
66
80
  "segments": segments,
67
81
  }
68
82
 
69
83
 
84
+ class _LazyFlags(dict[str, FlagResult[typing.Any]]):
85
+ """
86
+ The `$.flags` mapping, resolving a flag when a condition first reads it.
87
+ """
88
+
89
+ _context: _EvaluationContextAnyMeta
90
+
91
+ #: Names of the flags found to be in a dependency cycle.
92
+ cyclic: set[str]
93
+
94
+ #: Set the first time a condition reads a flag. Distinguishes "no
95
+ #: dependency was ever consulted" from "one was, and resolved to nothing",
96
+ #: which an empty mapping cannot.
97
+ used: bool = False
98
+
99
+ #: Incremented whenever a cycle is cut. A segment whose own evaluation
100
+ #: increments it read a flag that could not be resolved, so its verdict
101
+ #: rests on that flag's absence and it must not match.
102
+ cycle_hits: int = 0
103
+
104
+ def bind(self, context: _EvaluationContextAnyMeta) -> None:
105
+ self._context = context
106
+
107
+ @cached_property
108
+ def _resolver(self) -> _DependencyResolver[typing.Any, typing.Any]:
109
+ return _DependencyResolver(self._context, self)
110
+
111
+ def __missing__(self, key: str) -> typing.Optional[FlagResult[typing.Any]]:
112
+ if not self.used:
113
+ # Both are only needed once a condition has read a flag, so a
114
+ # context whose segments read none allocates neither.
115
+ self.used = True
116
+ self.cyclic = set()
117
+ self._resolver.resolve_feature(key)
118
+ return self.get(key)
119
+
120
+
70
121
  def get_enriched_context(
71
122
  context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
72
123
  ) -> EvaluationContext[SegmentMetadataT, FeatureMetadataT]:
@@ -90,8 +141,25 @@ def get_enriched_context(
90
141
  return context
91
142
 
92
143
 
144
+ def _wins_over(
145
+ candidate: FeatureContext[FeatureMetadataT],
146
+ incumbent: typing.Optional[FeatureContext[FeatureMetadataT]],
147
+ ) -> bool:
148
+ """
149
+ Whether a segment override takes precedence over the one held so far.
150
+
151
+ Lower priority wins, and the first seen wins a tie, so that precedence
152
+ follows context order rather than the order segments happen to be
153
+ evaluated in.
154
+ """
155
+ return incumbent is None or candidate.get(
156
+ "priority", constants.DEFAULT_PRIORITY
157
+ ) < incumbent.get("priority", constants.DEFAULT_PRIORITY)
158
+
159
+
93
160
  def evaluate_segments(
94
161
  context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
162
+ flags: "_LazyFlags",
95
163
  ) -> typing.Tuple[
96
164
  list[SegmentResult[SegmentMetadataT]],
97
165
  SegmentOverrides[FeatureMetadataT],
@@ -101,9 +169,18 @@ def evaluate_segments(
101
169
 
102
170
  segment_results: list[SegmentResult[SegmentMetadataT]] = []
103
171
  segment_overrides: SegmentOverrides[FeatureMetadataT] = {}
172
+ cycle_hits = flags.cycle_hits
104
173
 
105
174
  for segment_context in segment_contexts.values():
106
- if not is_context_in_segment(context, segment_context):
175
+ matches = is_context_in_segment(context, segment_context)
176
+
177
+ if (hits := flags.cycle_hits) != cycle_hits:
178
+ # Evaluating this segment cut a cycle, so it matched, or failed to,
179
+ # on a flag that could not be resolved. Neither verdict is sound.
180
+ cycle_hits = hits
181
+ continue
182
+
183
+ if not matches:
107
184
  continue
108
185
 
109
186
  segment_result: SegmentResult[SegmentMetadataT] = {
@@ -113,24 +190,17 @@ def evaluate_segments(
113
190
  segment_result["metadata"] = segment_metadata
114
191
  segment_results.append(segment_result)
115
192
 
116
- if overrides := segment_context.get("overrides"):
117
- for override_feature_context in overrides:
118
- feature_name = override_feature_context["name"]
119
- if (
120
- feature_name not in segment_overrides
121
- or override_feature_context.get(
122
- "priority",
123
- constants.DEFAULT_PRIORITY,
124
- )
125
- < (segment_overrides[feature_name]["feature_context"]).get(
126
- "priority",
127
- constants.DEFAULT_PRIORITY,
128
- )
129
- ):
130
- segment_overrides[feature_name] = SegmentOverride(
131
- feature_context=override_feature_context,
132
- segment_name=segment_context["name"],
133
- )
193
+ for override_feature_context in segment_context.get("overrides") or ():
194
+ feature_name = override_feature_context["name"]
195
+ incumbent = segment_overrides.get(feature_name)
196
+ if _wins_over(
197
+ override_feature_context,
198
+ incumbent["feature_context"] if incumbent else None,
199
+ ):
200
+ segment_overrides[feature_name] = SegmentOverride(
201
+ feature_context=override_feature_context,
202
+ segment_name=segment_context["name"],
203
+ )
134
204
 
135
205
  return segment_results, segment_overrides
136
206
 
@@ -162,6 +232,115 @@ def evaluate_features(
162
232
  return flags
163
233
 
164
234
 
235
+ _JSONPATH_PREFIX = "$."
236
+
237
+ CIRCULAR_DEPENDENCY_REASON = "ERROR; code=CIRCULAR_DEPENDENCY"
238
+
239
+
240
+ class _DependencyResolver(typing.Generic[SegmentMetadataT, FeatureMetadataT]):
241
+ """
242
+ Resolves overrides for a context with flag dependencies, memoising the results.
243
+ """
244
+
245
+ def __init__(
246
+ self,
247
+ context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
248
+ flags: "_LazyFlags",
249
+ ) -> None:
250
+ self._context = context
251
+ self._flags = flags
252
+ self._segment_matches: dict[str, bool] = {}
253
+ self._resolving: list[str] = []
254
+ self._segment_keys_by_feature_name: dict[str, list[str]] = {}
255
+ for segment_key, segment_context in (context.get("segments") or {}).items():
256
+ for override in segment_context.get("overrides") or ():
257
+ self._segment_keys_by_feature_name.setdefault(
258
+ override["name"], []
259
+ ).append(segment_key)
260
+
261
+ def resolve_feature(self, feature_name: str) -> None:
262
+ if feature_name in self._resolving:
263
+ # Cyclic dependency. Leave the flag unresolved.
264
+ cycle_start = self._resolving.index(feature_name)
265
+ self._flags.cyclic.update(self._resolving[cycle_start:])
266
+ self._flags.cycle_hits += 1
267
+ return
268
+ if not (
269
+ feature_context := (self._context.get("features") or {}).get(feature_name)
270
+ ):
271
+ # Depending on a feature absent from the context.
272
+ return
273
+
274
+ self._resolving.append(feature_name)
275
+ try:
276
+ segment_override = self._get_segment_override(feature_name)
277
+ finally:
278
+ # A `KeyError` raised under here is swallowed by the JSONPath
279
+ # implementation and evaluation carries on, so a name left on the
280
+ # stack would silently look like a cycle to a later read.
281
+ self._resolving.pop()
282
+
283
+ if feature_name in self._flags.cyclic:
284
+ # The result is not something another condition may match on.
285
+ return
286
+
287
+ if segment_override is not None:
288
+ segment_name = segment_override["segment_name"]
289
+ self._flags[feature_name] = get_flag_result_from_context(
290
+ context=self._context,
291
+ feature_context=segment_override["feature_context"],
292
+ reason=f"TARGETING_MATCH; segment={segment_name}",
293
+ )
294
+ else:
295
+ self._flags[feature_name] = get_flag_result_from_context(
296
+ context=self._context,
297
+ feature_context=feature_context,
298
+ reason="DEFAULT",
299
+ )
300
+
301
+ def matches_segment(self, segment_key: str) -> bool:
302
+ if (matches := self._segment_matches.get(segment_key)) is not None:
303
+ return matches
304
+
305
+ cycle_hits = self._flags.cycle_hits
306
+ matches = is_context_in_segment(
307
+ self._context,
308
+ (self._context.get("segments") or {})[segment_key],
309
+ )
310
+
311
+ if self._flags.cycle_hits != cycle_hits:
312
+ # Reached by cutting a cycle, so the verdict rests on a flag that
313
+ # could not be resolved. Not a match, and not worth memoising.
314
+ return False
315
+
316
+ self._segment_matches[segment_key] = matches
317
+ return matches
318
+
319
+ def _get_segment_override(
320
+ self,
321
+ feature_name: str,
322
+ ) -> typing.Optional[SegmentOverride[FeatureMetadataT]]:
323
+ segment_override: typing.Optional[SegmentOverride[FeatureMetadataT]] = None
324
+
325
+ for segment_key in self._segment_keys_by_feature_name.get(feature_name) or ():
326
+ if not self.matches_segment(segment_key):
327
+ continue
328
+ segment_context = (self._context.get("segments") or {})[segment_key]
329
+ for override_feature_context in segment_context.get("overrides") or ():
330
+ if override_feature_context["name"] != feature_name:
331
+ continue
332
+ if _wins_over(
333
+ override_feature_context,
334
+ segment_override["feature_context"] if segment_override else None,
335
+ ):
336
+ segment_override = SegmentOverride(
337
+ feature_context=override_feature_context,
338
+ segment_name=segment_context["name"],
339
+ )
340
+
341
+ return segment_override
342
+
343
+
165
344
  def get_flag_result_from_context(
166
345
  context: _EvaluationContextAnyMeta,
167
346
  feature_context: FeatureContext[FeatureMetadataT],
@@ -321,7 +500,7 @@ def get_context_value(
321
500
  property: str,
322
501
  ) -> ContextValue:
323
502
  value = None
324
- if property.startswith("$."):
503
+ if property.startswith(_JSONPATH_PREFIX):
325
504
  value = _get_context_value_getter(property)(context)
326
505
  else:
327
506
  value = _get_trait_value(context, property)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-flag-engine
3
- Version: 10.2.0
3
+ Version: 11.1.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,11 +8,12 @@ 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.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.10
16
17
  Description-Content-Type: text/markdown
17
18
  License-File: LICENSE.txt
18
19
  Requires-Dist: jsonpath-rfc9535<1,>=0.1.5
@@ -27,6 +28,7 @@ Dynamic: home-page
27
28
  Dynamic: license
28
29
  Dynamic: license-file
29
30
  Dynamic: requires-dist
31
+ Dynamic: requires-python
30
32
  Dynamic: summary
31
33
 
32
34
  [![Feature Flag, Remote Config and A/B Testing platform, Flagsmith](https://github.com/Flagsmith/flagsmith/raw/main/static-files/hero.png)](https://www.flagsmith.com/)
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="flagsmith-flag-engine",
5
- version="10.2.0",
5
+ version="11.1.0",
6
6
  author="Flagsmith",
7
7
  author_email="support@flagsmith.com",
8
8
  packages=find_packages(include=["flag_engine", "flag_engine.*"]),
@@ -12,6 +12,7 @@ setup(
12
12
  description="Flag engine for the Flagsmith API.",
13
13
  long_description=open("README.md").read(),
14
14
  long_description_content_type="text/markdown",
15
+ python_requires=">=3.10",
15
16
  install_requires=[
16
17
  "jsonpath-rfc9535>=0.1.5,<1",
17
18
  "semver>=3.0.4,<4",
@@ -20,10 +21,10 @@ setup(
20
21
  classifiers=[
21
22
  "License :: OSI Approved :: BSD License",
22
23
  "Programming Language :: Python :: 3",
23
- "Programming Language :: Python :: 3.9",
24
24
  "Programming Language :: Python :: 3.10",
25
25
  "Programming Language :: Python :: 3.11",
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
28
29
  ],
29
30
  )