Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions flagsmith/flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
Flags,
Segment,
SegmentOverridesIndex,
build_flag_dependent_feature_names,
build_segment_overrides_index,
)
from flagsmith.offline_handlers import OfflineHandler
Expand Down Expand Up @@ -137,6 +138,7 @@ def __init__(
self._event_processor: typing.Optional[EventProcessor] = None
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
self._segment_overrides_index: SegmentOverridesIndex = {}
self._flag_dependent_features: typing.FrozenSet[str] = frozenset()
self._environment_updated_at: typing.Optional[datetime] = None

# argument validation
Expand Down Expand Up @@ -491,15 +493,21 @@ def _evaluation_context(
) -> None:
"""Swap in a new evaluation context and rebuild the overrides index.

The index maps feature_name -> segments that override it. Built once
per refresh and reused across every subsequent per-identity lazy
resolution; rebuilding here keeps it in sync with the current doc
without any hot-path cost.
The index maps feature_name -> segments that override it, and the
flag-dependent set names features whose overriding segments read
`$.flags`. Both are built once per refresh and reused across every
subsequent per-identity lazy resolution; rebuilding here keeps them
in sync with the current doc without any hot-path cost.
"""
self.__evaluation_context = context
self._segment_overrides_index = (
build_segment_overrides_index(context) if context is not None else {}
)
self._flag_dependent_features = (
build_flag_dependent_feature_names(context)
if context is not None
else frozenset()
)

def _get_headers(
self,
Expand All @@ -523,13 +531,10 @@ def _get_environment_flags_from_document(self) -> Flags:
if self._evaluation_context is None:
raise TypeError("No environment present")

# Omit segments from evaluation context for environment flags
# as they are only relevant for identity-specific evaluations
context_without_segments = self._evaluation_context.copy()
context_without_segments.pop("segments", None)

# Segments are included so that identity-less conditions, e.g. on
# `$.environment` or `$.flags`, apply to environment flags too.
evaluation_result = engine.get_evaluation_result(
context=context_without_segments,
context=self._evaluation_context,
)

return Flags.from_evaluation_result(
Expand Down Expand Up @@ -557,6 +562,7 @@ def _get_identity_flags_from_document(
return Flags.from_evaluation_context(
context=context,
overrides_index=self._segment_overrides_index,
flag_dependent_features=self._flag_dependent_features,
analytics_processor=self._analytics_processor,
default_flag_handler=self.default_flag_handler,
)
Expand Down
107 changes: 97 additions & 10 deletions flagsmith/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import typing
from dataclasses import dataclass, field
from functools import lru_cache

import jsonpath_rfc9535
from flag_engine import engine
from flag_engine.context.types import SegmentContext
from flag_engine.context.types import SegmentContext, SegmentRule
from jsonpath_rfc9535.segments import JSONPathChildSegment
from jsonpath_rfc9535.selectors import NameSelector

from flagsmith.analytics import AnalyticsProcessor
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
Expand Down Expand Up @@ -41,6 +45,69 @@ def build_segment_overrides_index(
return index


@lru_cache(maxsize=1024)
def _property_may_read_flags(condition_property: str) -> bool:
"""Whether a condition property may read the engine's `$.flags`.

Mirrors how the engine reads properties: only those starting with
`$.` that compile as JSONPath are queried against the context;
anything else is a trait key. The compiled query is then checked
the way the engine would run it, rather than by pattern matching.

Errs on the side of `True`: a false positive only costs a
full-context evaluation, whereas a false negative would evaluate a
flag without its dependencies. So the property is ruled out only
when its first segment selects top-level keys by name, none of them
`flags`. Wildcards, filters, indices and descendant segments
(`$..`) are all assumed to reach `flags`.
"""
if not condition_property.startswith("$."):
return False
try:
query = jsonpath_rfc9535.compile(condition_property)
except jsonpath_rfc9535.JSONPathSyntaxError:
return False
if not query.segments:
return False
first_segment = query.segments[0]
return not (
type(first_segment) is JSONPathChildSegment
and all(
isinstance(selector, NameSelector) and selector.name != "flags"
for selector in first_segment.selectors
)
)


def _rules_read_flags(rules: typing.Iterable[SegmentRule]) -> bool:
return any(
any(
_property_may_read_flags(condition.get("property") or "")
for condition in rule.get("conditions") or ()
)
or _rules_read_flags(rule.get("rules") or ())
for rule in rules
)


def build_flag_dependent_feature_names(
context: SDKEvaluationContext,
) -> typing.FrozenSet[str]:
"""Names of features overridden by a segment with a `$.flags` condition.

Such a feature depends on other flags, which the engine resolves
from the context's features and segments. The trimmed contexts used
by `Flags._resolve_flag` leave those out, so these features are
resolved against the full context instead.
"""
return frozenset(
override["name"]
for segment_context in (context.get("segments") or {}).values()
if _rules_read_flags(segment_context["rules"])
for override in segment_context.get("overrides") or ()
)


@dataclass(frozen=True)
class ExperimentMetadata:
"""The running experiment a flag was evaluated under; identity evaluations only."""
Expand Down Expand Up @@ -127,6 +194,7 @@ class Flags:
# paths (`from_evaluation_result` / `from_api_flags`).
_context: typing.Optional[SDKEvaluationContext] = None
_overrides_index: typing.Optional[SegmentOverridesIndex] = None
_flag_dependent_features: typing.FrozenSet[str] = frozenset()
_fully_materialised: bool = False

@classmethod
Expand All @@ -153,20 +221,26 @@ def from_evaluation_context(
overrides_index: SegmentOverridesIndex,
analytics_processor: typing.Optional[AnalyticsProcessor],
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
flag_dependent_features: typing.Optional[typing.FrozenSet[str]] = None,
) -> Flags:
"""Build a lazy `Flags` backed by an evaluation context.

No engine work is done here — flags are resolved on first access
via :meth:`_resolve_flag`. Reusing the same `overrides_index`
across calls amortises its construction cost (it's rebuilt only
when the environment doc refreshes, not per identity).
and `flag_dependent_features` across calls amortises their
construction cost (they're rebuilt only when the environment doc
refreshes, not per identity). When `flag_dependent_features` is
omitted, it is derived from `context`.
"""
if flag_dependent_features is None:
flag_dependent_features = build_flag_dependent_feature_names(context)
return cls(
flags={},
default_flag_handler=default_flag_handler,
_analytics_processor=analytics_processor,
_context=context,
_overrides_index=overrides_index,
_flag_dependent_features=flag_dependent_features,
)

@classmethod
Expand Down Expand Up @@ -199,15 +273,19 @@ def all_flags(self) -> typing.List[Flag]:
:return: list of Flag objects.
"""
if self._context is not None and not self._fully_materialised:
result = engine.get_evaluation_result(self._context)
for feature_name, flag_result in result["flags"].items():
if feature_name not in self.flags:
self.flags[feature_name] = Flag.from_evaluation_result(
flag_result,
)
self._fully_materialised = True
self._materialise_all()
return list(self.flags.values())

def _materialise_all(self) -> None:
assert self._context is not None
result = engine.get_evaluation_result(self._context)
for feature_name, flag_result in result["flags"].items():
if feature_name not in self.flags:
self.flags[feature_name] = Flag.from_evaluation_result(
flag_result,
)
self._fully_materialised = True

def is_feature_enabled(self, feature_name: str) -> bool:
"""
Check whether a given feature is enabled.
Expand Down Expand Up @@ -273,13 +351,22 @@ def _resolve_flag(self, feature_name: str) -> Flag:
that could override it, looked up in O(1) via the precomputed
reverse index — so the engine's full pipeline runs against an
input small enough to evaluate in ~1 µs.

Features overridden by a segment with a `$.flags` condition
depend on other flags, which a trimmed context would leave out,
so they are resolved with the full context instead. Every flag
that evaluation yields is memoised.
"""
context = self._context
overrides_index = self._overrides_index
# `get_flag` / `all_flags` gate this call behind the same
# non-None checks; assert here so type checkers can narrow.
assert context is not None and overrides_index is not None

if feature_name in self._flag_dependent_features:
self._materialise_all()
return self.flags[feature_name]

trimmed: SDKEvaluationContext = {
**context,
"features": {feature_name: context["features"][feature_name]},
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ packages = [{ include = "flagsmith" }]
[tool.poetry.dependencies]
flagsmith-flag-engine = "^11.1.1"
iso8601 = { version = "^2.1.0", python = "<3.11" }
jsonpath-rfc9535 = ">=0.1.5,<1"
python = ">=3.10,<4"
requests = "^2.32.3"
requests-futures = "^1.0.1"
Expand Down
Loading
Loading