Skip to content

Commit 39e1355

Browse files
committed
feat(v7): JSONPath support
1 parent 8e07687 commit 39e1355

File tree

5 files changed

+64
-36
lines changed

5 files changed

+64
-36
lines changed

flag_engine/segments/evaluator.py

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import typing
55
import warnings
66
from contextlib import suppress
7-
from functools import partial, wraps
7+
from functools import lru_cache, wraps
88

9+
import jsonpath_rfc9535
910
import semver
1011

1112
from flag_engine.context.mappers import map_environment_identity_to_context
@@ -23,7 +24,7 @@
2324
from flag_engine.segments import constants
2425
from flag_engine.segments.models import SegmentModel
2526
from flag_engine.segments.types import ConditionOperator
26-
from flag_engine.segments.utils import get_matching_function
27+
from flag_engine.segments.utils import escape_double_quotes, get_matching_function
2728
from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids
2829
from flag_engine.utils.semver import is_semver
2930
from flag_engine.utils.types import SupportsStr, get_casting_function
@@ -256,26 +257,11 @@ def context_matches_condition(
256257
)
257258

258259

259-
def _get_trait(context: EvaluationContext, trait_key: str) -> ContextValue:
260-
return (
261-
identity_context["traits"][trait_key]
262-
if (identity_context := context["identity"])
263-
else None
264-
)
265-
266-
267260
def get_context_value(
268261
context: EvaluationContext,
269262
property: str,
270263
) -> ContextValue:
271-
getter = CONTEXT_VALUE_GETTERS_BY_PROPERTY.get(property) or partial(
272-
_get_trait,
273-
trait_key=property,
274-
)
275-
try:
276-
return getter(context)
277-
except KeyError:
278-
return None
264+
return _get_context_value_getter(property)(context)
279265

280266

281267
def _matches_context_value(
@@ -385,8 +371,36 @@ def inner(
385371
}
386372

387373

388-
CONTEXT_VALUE_GETTERS_BY_PROPERTY = {
389-
"$.identity.identifier": lambda context: context["identity"]["identifier"],
390-
"$.identity.key": lambda context: context["identity"]["key"],
391-
"$.environment.name": lambda context: context["environment"]["name"],
392-
}
374+
@lru_cache
375+
def _get_context_value_getter(
376+
property: str,
377+
) -> typing.Callable[[EvaluationContext], ContextValue]:
378+
"""
379+
Get a function to retrieve a context value based on property value,
380+
assumed to be either a JSONPath string or a trait key.
381+
382+
:param property: The property to retrieve the value for.
383+
:return: A function that takes an EvaluationContext and returns the value.
384+
"""
385+
try:
386+
compiled_query = jsonpath_rfc9535.compile(property)
387+
except jsonpath_rfc9535.JSONPathSyntaxError:
388+
compiled_query = jsonpath_rfc9535.compile(
389+
f'$.identity.traits["{escape_double_quotes(property)}"]',
390+
)
391+
392+
def getter(context: EvaluationContext) -> ContextValue:
393+
try:
394+
if result := compiled_query.find_one(context):
395+
return result.value
396+
return None
397+
except jsonpath_rfc9535.JSONPathError: # pragma: no cover
398+
# This is supposed to be unreachable, but if it happens,
399+
# we log a warning and return None.
400+
warnings.warn(
401+
f"Failed to evaluate JSONPath query '{property}' in context: {context}",
402+
RuntimeWarning,
403+
)
404+
return None
405+
406+
return getter

flag_engine/segments/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ def get_matching_function(
1515

1616
def none(iterable: typing.Iterable[object]) -> bool:
1717
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('"', '\\"')

requirements.in

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
annotated-types
2-
semver
2+
jsonpath-rfc9535
33
pydantic
44
pydantic-collections
5+
semver
56
typing_extensions

requirements.txt

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
1-
#
2-
# This file is autogenerated by pip-compile with Python 3.11
3-
# by the following command:
4-
#
5-
# pip-compile
6-
#
7-
annotated-types==0.5.0
1+
# This file was autogenerated by uv via the following command:
2+
# uv pip compile requirements.in --constraints requirements.txt
3+
annotated-types==0.7.0
84
# via
95
# -r requirements.in
106
# pydantic
11-
pydantic==2.4.0
7+
iregexp-check==0.1.4
8+
# via jsonpath-rfc9535
9+
jsonpath-rfc9535==0.1.5
10+
# via -r requirements.in
11+
pydantic==2.11.7
1212
# via
1313
# -r requirements.in
1414
# pydantic-collections
15-
pydantic-collections==0.5.1
15+
pydantic-collections==0.6.0
1616
# via -r requirements.in
17-
pydantic-core==2.10.0
17+
pydantic-core==2.33.2
1818
# via pydantic
19-
semver==3.0.1
19+
regex==2025.7.34
20+
# via jsonpath-rfc9535
21+
semver==3.0.4
2022
# via -r requirements.in
21-
typing-extensions==4.8.0
23+
typing-extensions==4.14.1
2224
# via
2325
# -r requirements.in
2426
# pydantic
2527
# pydantic-collections
2628
# pydantic-core
29+
# typing-inspection
30+
typing-inspection==0.4.1
31+
# via pydantic

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
long_description=open("README.md").read(),
1414
long_description_content_type="text/markdown",
1515
install_requires=[
16+
"jsonpath-rfc9535>=0.1.5,<1",
1617
"pydantic>=2.3.0,<3",
1718
"pydantic-collections>=0.5.1,<1",
1819
"semver>=3.0.1",

0 commit comments

Comments
 (0)