Skip to content

Commit 202e548

Browse files
feat: add set_evaluation_context and get_evaluation_context to OpenFeatureClient
Allow setting evaluation context at the client level, following the OpenFeature spec for context merging: API-level context is merged with client-level context, then with invocation-level context, where later levels take precedence over earlier ones. Closes #500 Signed-off-by: buildingisfun23 <buildingisfun23@users.noreply.github.com>
1 parent 9478ea0 commit 202e548

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed

openfeature/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ def get_provider_status(self) -> ProviderStatus:
9797
def get_metadata(self) -> ClientMetadata:
9898
return ClientMetadata(domain=self.domain)
9999

100+
def set_evaluation_context(self, context: EvaluationContext) -> None:
101+
self.context = context
102+
103+
def get_evaluation_context(self) -> EvaluationContext:
104+
return self.context
105+
100106
def add_hooks(self, hooks: list[Hook]) -> None:
101107
self.hooks = self.hooks + hooks
102108

tests/test_client.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,3 +666,159 @@ def test_should_noop_if_provider_does_not_support_tracking(monkeypatch):
666666
set_provider(provider)
667667
client = get_client()
668668
client.track(tracking_event_name="test")
669+
670+
671+
def test_client_set_evaluation_context():
672+
"""Test that set_evaluation_context sets the client-level context."""
673+
client = OpenFeatureClient(domain=None, version=None)
674+
ctx = EvaluationContext(
675+
targeting_key="user-123", attributes={"env": "production"}
676+
)
677+
client.set_evaluation_context(ctx)
678+
assert client.get_evaluation_context() == ctx
679+
assert client.context is ctx
680+
681+
682+
def test_client_get_evaluation_context_default():
683+
"""Test that a new client has an empty evaluation context by default."""
684+
client = OpenFeatureClient(domain=None, version=None)
685+
ctx = client.get_evaluation_context()
686+
assert ctx is not None
687+
assert ctx.targeting_key is None
688+
assert ctx.attributes == {}
689+
690+
691+
def test_client_set_evaluation_context_replaces_previous():
692+
"""Test that set_evaluation_context replaces any previously set context."""
693+
client = OpenFeatureClient(domain=None, version=None)
694+
ctx1 = EvaluationContext(targeting_key="first", attributes={"a": "1"})
695+
ctx2 = EvaluationContext(targeting_key="second", attributes={"b": "2"})
696+
client.set_evaluation_context(ctx1)
697+
assert client.get_evaluation_context().targeting_key == "first"
698+
client.set_evaluation_context(ctx2)
699+
assert client.get_evaluation_context().targeting_key == "second"
700+
assert client.get_evaluation_context().attributes == {"b": "2"}
701+
702+
703+
def test_client_evaluation_context_merging_with_api_and_invocation():
704+
"""Test context merging order: API -> client -> invocation (invocation wins)."""
705+
api.clear_hooks()
706+
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
707+
708+
provider = NoOpProvider()
709+
provider.resolve_boolean_details = MagicMock(wraps=provider.resolve_boolean_details)
710+
api.set_provider(provider)
711+
712+
# API-level context
713+
api.set_evaluation_context(
714+
EvaluationContext(
715+
targeting_key="api",
716+
attributes={"shared": "api_value", "api_only": "from_api"},
717+
)
718+
)
719+
720+
# Client-level context (set via set_evaluation_context)
721+
client = OpenFeatureClient(domain=None, version=None)
722+
client.set_evaluation_context(
723+
EvaluationContext(
724+
targeting_key="client",
725+
attributes={"shared": "client_value", "client_only": "from_client"},
726+
)
727+
)
728+
729+
# Invocation-level context
730+
invocation_context = EvaluationContext(
731+
targeting_key="invocation",
732+
attributes={"shared": "invocation_value", "invocation_only": "from_invocation"},
733+
)
734+
735+
client.get_boolean_details("flag", False, invocation_context)
736+
737+
_, kwargs = provider.resolve_boolean_details.call_args
738+
context = kwargs["evaluation_context"]
739+
740+
# Invocation targeting_key wins (last in merge chain)
741+
assert context.targeting_key == "invocation"
742+
# Invocation attribute wins for shared key
743+
assert context.attributes["shared"] == "invocation_value"
744+
# All levels contribute their unique attributes
745+
assert context.attributes["api_only"] == "from_api"
746+
assert context.attributes["client_only"] == "from_client"
747+
assert context.attributes["invocation_only"] == "from_invocation"
748+
749+
750+
def test_client_evaluation_context_merging_without_invocation():
751+
"""Test context merging when no invocation context is provided."""
752+
api.clear_hooks()
753+
754+
provider = NoOpProvider()
755+
provider.resolve_boolean_details = MagicMock(wraps=provider.resolve_boolean_details)
756+
api.set_provider(provider)
757+
758+
api.set_evaluation_context(
759+
EvaluationContext(
760+
targeting_key="api",
761+
attributes={"shared": "api_value", "api_only": "from_api"},
762+
)
763+
)
764+
765+
client = OpenFeatureClient(domain=None, version=None)
766+
client.set_evaluation_context(
767+
EvaluationContext(
768+
targeting_key="client",
769+
attributes={"shared": "client_value", "client_only": "from_client"},
770+
)
771+
)
772+
773+
# No invocation context
774+
client.get_boolean_details("flag", False)
775+
776+
_, kwargs = provider.resolve_boolean_details.call_args
777+
context = kwargs["evaluation_context"]
778+
779+
# Client targeting_key wins over API
780+
assert context.targeting_key == "client"
781+
assert context.attributes["shared"] == "client_value"
782+
assert context.attributes["api_only"] == "from_api"
783+
assert context.attributes["client_only"] == "from_client"
784+
785+
786+
@pytest.mark.asyncio
787+
async def test_client_evaluation_context_merging_async():
788+
"""Test context merging works correctly for async evaluation."""
789+
api.clear_hooks()
790+
791+
provider = NoOpProvider()
792+
provider.resolve_boolean_details_async = MagicMock(
793+
wraps=provider.resolve_boolean_details_async
794+
)
795+
api.set_provider(provider)
796+
797+
api.set_evaluation_context(
798+
EvaluationContext(
799+
targeting_key="api",
800+
attributes={"level": "api"},
801+
)
802+
)
803+
804+
client = OpenFeatureClient(domain=None, version=None)
805+
client.set_evaluation_context(
806+
EvaluationContext(
807+
targeting_key="client",
808+
attributes={"level": "client", "client_attr": "yes"},
809+
)
810+
)
811+
812+
invocation_context = EvaluationContext(
813+
targeting_key="invocation",
814+
attributes={"level": "invocation"},
815+
)
816+
817+
await client.get_boolean_details_async("flag", False, invocation_context)
818+
819+
_, kwargs = provider.resolve_boolean_details_async.call_args
820+
context = kwargs["evaluation_context"]
821+
822+
assert context.targeting_key == "invocation"
823+
assert context.attributes["level"] == "invocation"
824+
assert context.attributes["client_attr"] == "yes"

0 commit comments

Comments
 (0)