Skip to content

Commit 5b0136f

Browse files
authored
feat(v7): JSONPath support (#243)
1 parent 5376cb9 commit 5b0136f

File tree

10 files changed

+133
-51
lines changed

10 files changed

+133
-51
lines changed

.github/workflows/pull-request.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,17 @@ jobs:
3030
uses: actions/setup-python@v5
3131
with:
3232
python-version: ${{ matrix.python-version }}
33-
3433
- name: Install Dependencies
35-
if: ${{ matrix.python-version == '3.8' }}
34+
if: ${{ matrix.python-version != '3.8' }}
3635
run: |
3736
python -m pip install --upgrade pip
38-
pip install -r requirements.txt -r requirements-dev-3.8.txt
37+
pip install -r requirements.txt -r requirements-dev.txt
3938
4039
- name: Install Dependencies
41-
if: ${{ matrix.python-version != '3.8' }}
40+
if: ${{ matrix.python-version == '3.8' }}
4241
run: |
4342
python -m pip install --upgrade pip
44-
pip install -r requirements.txt -r requirements-dev.txt
43+
pip install -r requirements.txt -r requirements-dev-3.8.txt
4544
4645
- name: Check Formatting
4746
run: black --check .

flag_engine/identities/traits/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
_UnconstrainedContextValue = Union[None, int, float, bool, str]
1212

1313

14+
def is_trait_value(value: Any) -> TypeGuard[_UnconstrainedContextValue]:
15+
"""
16+
Check if the value is a valid trait value type.
17+
This function is used to determine if a value can be treated as a trait value.
18+
"""
19+
return isinstance(value, get_args(_UnconstrainedContextValue))
20+
21+
1422
def map_any_value_to_trait_value(value: Any) -> _UnconstrainedContextValue:
1523
"""
1624
Try to coerce a value of arbitrary type to a trait value type.

flag_engine/segments/evaluator.py

Lines changed: 51 additions & 24 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
@@ -18,12 +19,12 @@
1819
)
1920
from flag_engine.environments.models import EnvironmentModel
2021
from flag_engine.identities.models import IdentityModel
21-
from flag_engine.identities.traits.types import ContextValue
22+
from flag_engine.identities.traits.types import ContextValue, is_trait_value
2223
from flag_engine.result.types import EvaluationResult, FlagResult, SegmentResult
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,16 @@ 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+
if property.startswith("$."):
265+
return _get_context_value_getter(property)(context)
266+
if identity_context := context.get("identity"):
267+
if traits := identity_context.get("traits"):
268+
return traits.get(property)
269+
return None
279270

280271

281272
def _matches_context_value(
@@ -385,8 +376,44 @@ def inner(
385376
}
386377

387378

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-
}
379+
@lru_cache
380+
def _get_context_value_getter(
381+
property: str,
382+
) -> typing.Callable[[EvaluationContext], ContextValue]:
383+
"""
384+
Get a function to retrieve a context value based on property value,
385+
assumed to be either a JSONPath string or a trait key.
386+
387+
:param property: The property to retrieve the value for.
388+
:return: A function that takes an EvaluationContext and returns the value.
389+
"""
390+
try:
391+
compiled_query = jsonpath_rfc9535.compile(property)
392+
except jsonpath_rfc9535.JSONPathSyntaxError:
393+
# This covers a rare case when a trait starting with "$.",
394+
# but not a valid JSONPath, is used.
395+
compiled_query = jsonpath_rfc9535.compile(
396+
f'$.identity.traits["{escape_double_quotes(property)}"]',
397+
)
398+
399+
def getter(context: EvaluationContext) -> ContextValue:
400+
if typing.TYPE_CHECKING: # pragma: no cover
401+
# Ugly hack to satisfy mypy :(
402+
data = dict(context)
403+
else:
404+
data = context
405+
try:
406+
if result := compiled_query.find_one(data):
407+
if is_trait_value(value := result.value):
408+
return value
409+
return None
410+
except jsonpath_rfc9535.JSONPathError: # pragma: no cover
411+
# This is supposed to be unreachable, but if it happens,
412+
# we log a warning and return None.
413+
warnings.warn(
414+
f"Failed to evaluate JSONPath query '{property}' in context: {context}",
415+
RuntimeWarning,
416+
)
417+
return None
418+
419+
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-dev-3.8.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# uv pip compile requirements-dev.in --constraints requirements.txt -o requirements-dev.txt --python-version 3.8
33
absolufy-imports==0.3.1
44
# via -r requirements-dev.in
5-
annotated-types==0.5.0
5+
annotated-types==0.7.0
66
# via
77
# -c requirements.txt
88
# pydantic
@@ -74,11 +74,11 @@ pycodestyle==2.9.1
7474
# via flake8
7575
pycparser==2.22
7676
# via cffi
77-
pydantic==2.4.0
77+
pydantic==2.10.6
7878
# via
7979
# -c requirements.txt
8080
# datamodel-code-generator
81-
pydantic-core==2.10.0
81+
pydantic-core==2.27.2
8282
# via
8383
# -c requirements.txt
8484
# pydantic
@@ -118,7 +118,7 @@ tomli==2.2.1
118118
# pytest
119119
types-setuptools==75.8.0.20250110
120120
# via -r requirements-dev.in
121-
typing-extensions==4.8.0
121+
typing-extensions==4.13.2
122122
# via
123123
# -c requirements.txt
124124
# annotated-types

requirements-dev.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# uv pip compile requirements-dev.in --constraints requirements.txt -o requirements-dev.txt --python-version 3.9
33
absolufy-imports==0.3.1
44
# via -r requirements-dev.in
5-
annotated-types==0.5.0
5+
annotated-types==0.7.0
66
# via
77
# -c requirements.txt
88
# pydantic
@@ -85,11 +85,11 @@ pycodestyle==2.14.0
8585
# via flake8
8686
pycparser==2.22
8787
# via cffi
88-
pydantic==2.4.0
88+
pydantic==2.10.6
8989
# via
9090
# -c requirements.txt
9191
# datamodel-code-generator
92-
pydantic-core==2.10.0
92+
pydantic-core==2.27.2
9393
# via
9494
# -c requirements.txt
9595
# pydantic
@@ -133,11 +133,11 @@ tomli==2.2.1
133133
# mypy
134134
# pip-tools
135135
# pytest
136-
typeguard==4.2.0
136+
typeguard==4.4.2
137137
# via inflect
138138
types-setuptools==80.9.0.20250809
139139
# via -r requirements-dev.in
140-
typing-extensions==4.8.0
140+
typing-extensions==4.13.2
141141
# via
142142
# -c requirements.txt
143143
# black

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: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
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 --python-version 3.8
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.6
10+
# via -r requirements.in
11+
pydantic==2.10.6
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.27.2
1818
# via pydantic
19-
semver==3.0.1
19+
regex==2024.11.6
20+
# via jsonpath-rfc9535
21+
semver==3.0.4
2022
# via -r requirements.in
21-
typing-extensions==4.8.0
23+
typing-extensions==4.13.2
2224
# via
2325
# -r requirements.in
26+
# annotated-types
2427
# pydantic
2528
# pydantic-collections
2629
# pydantic-core

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",

tests/unit/segments/test_segments_evaluator.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_matches_context_value,
2222
context_matches_condition,
2323
get_context_segments,
24+
get_context_value,
2425
get_evaluation_result,
2526
get_flag_result_from_feature_context,
2627
get_identity_segments,
@@ -1028,3 +1029,38 @@ def test_get_flag_result_from_feature_context__call_return_expected(
10281029
expected_key,
10291030
]
10301031
)
1032+
1033+
1034+
@pytest.mark.parametrize(
1035+
"property",
1036+
[
1037+
pytest.param("$.identity", id="jsonpath returns an object"),
1038+
'trait key "with quotes"',
1039+
"$.leads.to.nowhere",
1040+
],
1041+
)
1042+
def test_get_context_value__invalid_jsonpath__returns_expected(
1043+
context: EvaluationContext,
1044+
property: str,
1045+
) -> None:
1046+
# Given & When
1047+
result = get_context_value(context, property)
1048+
1049+
# Then
1050+
assert result is None
1051+
1052+
1053+
def test_get_context_value__jsonpath_like_trait__returns_expected(
1054+
context: EvaluationContext,
1055+
) -> None:
1056+
# Given
1057+
jsonpath_like_trait = '$. i am not" a valid jsonpath'
1058+
expected_result = "some_value"
1059+
assert context["identity"]
1060+
context["identity"]["traits"][jsonpath_like_trait] = expected_result
1061+
1062+
# When
1063+
result = get_context_value(context, jsonpath_like_trait)
1064+
1065+
# Then
1066+
assert result == expected_result

0 commit comments

Comments
 (0)