Skip to content

Commit a4abc37

Browse files
committed
test(e2e): add Behave step definitions for context merging
Add the missing E2E step definitions so the contextMerging.feature scenarios from the OpenFeature spec run against python-sdk. Fixes #500 Changes: - Bump spec submodule to 130df3eb so contextMerging.feature is copied in during the `poe e2e` task. - Add tests/features/environment.py with a before_scenario hook that resets provider/hook/API-context/transaction-context state, so scenarios cannot leak state between features. - Add tests/features/steps/context_merging_steps.py: - RetrievableContextProvider captures the merged EvaluationContext it receives, so assertions can inspect what the SDK merged. - Step definitions for all scenarios in contextMerging.feature: single-level insert, multi-level insert, and per-key overwrite precedence across API / Transaction / Client / Invocation / Before Hooks. - Client-level context is set via direct attribute assignment on OpenFeatureClient.context (no new setter), since merging already honors client.context (openfeature/client.py:422-429). Runs clean: 4 features / 50 scenarios / 233 steps.
1 parent 7d9229f commit a4abc37

File tree

3 files changed

+231
-1
lines changed

3 files changed

+231
-1
lines changed

tests/features/environment.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from openfeature import api
2+
from openfeature.evaluation_context import EvaluationContext
3+
from openfeature.transaction_context import (
4+
ContextVarsTransactionContextPropagator,
5+
set_transaction_context,
6+
set_transaction_context_propagator,
7+
)
8+
9+
10+
def before_scenario(context, scenario):
11+
api.clear_providers()
12+
api.clear_hooks()
13+
api.set_evaluation_context(EvaluationContext())
14+
set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
15+
set_transaction_context(EvaluationContext())
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
from collections.abc import Mapping, Sequence
5+
6+
from behave import given, then, when
7+
8+
from openfeature import api
9+
from openfeature.evaluation_context import EvaluationContext
10+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason
11+
from openfeature.hook import Hook, HookContext, HookHints
12+
from openfeature.provider import AbstractProvider, Metadata
13+
from openfeature.transaction_context import (
14+
ContextVarsTransactionContextPropagator,
15+
set_transaction_context,
16+
set_transaction_context_propagator,
17+
)
18+
19+
20+
class RetrievableContextProvider(AbstractProvider):
21+
"""Stores the last merged evaluation context it was asked to resolve."""
22+
23+
def __init__(self) -> None:
24+
self.last_context: EvaluationContext | None = None
25+
26+
def get_metadata(self) -> Metadata:
27+
return Metadata(name="retrievable-context-provider")
28+
29+
def get_provider_hooks(self) -> list[Hook]:
30+
return []
31+
32+
def _capture(
33+
self, default_value: FlagValueType, context: EvaluationContext | None
34+
) -> FlagResolutionDetails[typing.Any]:
35+
self.last_context = context
36+
return FlagResolutionDetails(value=default_value, reason=Reason.STATIC)
37+
38+
def resolve_boolean_details(
39+
self,
40+
flag_key: str,
41+
default_value: bool,
42+
evaluation_context: EvaluationContext | None = None,
43+
) -> FlagResolutionDetails[bool]:
44+
return self._capture(default_value, evaluation_context)
45+
46+
def resolve_string_details(
47+
self,
48+
flag_key: str,
49+
default_value: str,
50+
evaluation_context: EvaluationContext | None = None,
51+
) -> FlagResolutionDetails[str]:
52+
return self._capture(default_value, evaluation_context)
53+
54+
def resolve_integer_details(
55+
self,
56+
flag_key: str,
57+
default_value: int,
58+
evaluation_context: EvaluationContext | None = None,
59+
) -> FlagResolutionDetails[int]:
60+
return self._capture(default_value, evaluation_context)
61+
62+
def resolve_float_details(
63+
self,
64+
flag_key: str,
65+
default_value: float,
66+
evaluation_context: EvaluationContext | None = None,
67+
) -> FlagResolutionDetails[float]:
68+
return self._capture(default_value, evaluation_context)
69+
70+
def resolve_object_details(
71+
self,
72+
flag_key: str,
73+
default_value: Sequence[FlagValueType] | Mapping[str, FlagValueType],
74+
evaluation_context: EvaluationContext | None = None,
75+
) -> FlagResolutionDetails[Sequence[FlagValueType] | Mapping[str, FlagValueType]]:
76+
return self._capture(default_value, evaluation_context)
77+
78+
79+
class _BeforeHookContextInjector(Hook):
80+
def __init__(self, context: EvaluationContext) -> None:
81+
self._context = context
82+
83+
def before(
84+
self, hook_context: HookContext, hints: HookHints
85+
) -> EvaluationContext | None:
86+
return self._context
87+
88+
89+
_LEVELS = {"API", "Transaction", "Client", "Invocation", "Before Hooks"}
90+
91+
92+
def _ensure_state(context: typing.Any) -> None:
93+
if getattr(context, "_merging_initialized", False):
94+
return
95+
api.clear_providers()
96+
api.clear_hooks()
97+
api.set_evaluation_context(EvaluationContext())
98+
set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
99+
set_transaction_context(EvaluationContext())
100+
101+
provider = RetrievableContextProvider()
102+
api.set_provider(provider)
103+
context.provider = provider
104+
context.client = api.get_client()
105+
context.invocation_context = EvaluationContext()
106+
context.before_hook_context = EvaluationContext()
107+
context._merging_initialized = True
108+
109+
110+
@given("a stable provider with retrievable context is registered")
111+
def step_impl_retrievable_provider(context: typing.Any) -> None:
112+
context._merging_initialized = False
113+
_ensure_state(context)
114+
115+
116+
def _add_entry_at_level(
117+
context: typing.Any, level: str, key: str, value: typing.Any
118+
) -> None:
119+
_ensure_state(context)
120+
if level not in _LEVELS:
121+
raise ValueError(f"Unknown level: {level!r}")
122+
if level == "API":
123+
current = api.get_evaluation_context()
124+
api.set_evaluation_context(
125+
EvaluationContext(
126+
targeting_key=current.targeting_key,
127+
attributes={**current.attributes, key: value},
128+
)
129+
)
130+
elif level == "Transaction":
131+
current = api.get_transaction_context()
132+
set_transaction_context(
133+
EvaluationContext(
134+
targeting_key=current.targeting_key,
135+
attributes={**current.attributes, key: value},
136+
)
137+
)
138+
elif level == "Client":
139+
current = context.client.context
140+
context.client.context = EvaluationContext(
141+
targeting_key=current.targeting_key,
142+
attributes={**current.attributes, key: value},
143+
)
144+
elif level == "Invocation":
145+
current = context.invocation_context
146+
context.invocation_context = EvaluationContext(
147+
targeting_key=current.targeting_key,
148+
attributes={**current.attributes, key: value},
149+
)
150+
elif level == "Before Hooks":
151+
current = context.before_hook_context
152+
context.before_hook_context = EvaluationContext(
153+
targeting_key=current.targeting_key,
154+
attributes={**current.attributes, key: value},
155+
)
156+
157+
158+
@given(
159+
'A context entry with key "{key}" and value "{value}" is added to the '
160+
'"{level}" level'
161+
)
162+
def step_impl_add_entry(context: typing.Any, key: str, value: str, level: str) -> None:
163+
_add_entry_at_level(context, level, key, value)
164+
165+
166+
@given("A table with levels of increasing precedence")
167+
def step_impl_levels_table(context: typing.Any) -> None:
168+
_ensure_state(context)
169+
# The feature table is a single-column list of levels. Behave treats the
170+
# first row as the heading, so recombine heading + body rows.
171+
levels = [context.table.headings[0]]
172+
levels.extend(row[0] for row in context.table.rows)
173+
context.precedence_levels = levels
174+
175+
176+
@given(
177+
'Context entries for each level from API level down to the "{level}" level, '
178+
'with key "{key}" and value "{value}"'
179+
)
180+
def step_impl_entries_down_to(
181+
context: typing.Any, level: str, key: str, value: str
182+
) -> None:
183+
_ensure_state(context)
184+
levels = context.precedence_levels
185+
if level not in levels:
186+
raise ValueError(f"Level {level!r} not in precedence table {levels!r}")
187+
for current_level in levels:
188+
_add_entry_at_level(context, current_level, key, value)
189+
if current_level == level:
190+
break
191+
192+
193+
@when("Some flag was evaluated")
194+
def step_impl_evaluate(context: typing.Any) -> None:
195+
_ensure_state(context)
196+
hook = _BeforeHookContextInjector(context.before_hook_context)
197+
context.client.add_hooks([hook])
198+
try:
199+
context.client.get_boolean_details(
200+
"some-flag", False, context.invocation_context
201+
)
202+
finally:
203+
context.client.hooks = [h for h in context.client.hooks if h is not hook]
204+
205+
206+
@then('The merged context contains an entry with key "{key}" and value "{value}"')
207+
def step_impl_merged_contains(context: typing.Any, key: str, value: str) -> None:
208+
assert context.provider.last_context is not None, (
209+
"provider did not receive an evaluation context"
210+
)
211+
attributes = context.provider.last_context.attributes
212+
assert key in attributes, f"key {key!r} missing from merged context: {attributes!r}"
213+
assert attributes[key] == value, (
214+
f"expected {key!r}={value!r}, got {attributes[key]!r}"
215+
)

0 commit comments

Comments
 (0)