Skip to content

Commit 79bb01b

Browse files
balgalyjonathannorrisgruebelopenfeaturebotrenovate[bot]
authored
test(e2e): add Behave step definitions for context merging (#593)
* 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. Signed-off-by: gruebel <anton.gruebel@gmail.com> * docs: fix inaccuracies in README code examples (#592) Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * fix: correctly reset api state on shutdown (#589) correctly reset api state on shutdown Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(main): release 0.9.0 (#555) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update pre-commit hook tox-dev/pyproject-fmt to v2.21.2 (#601) * chore(deps): update pre-commit hook tox-dev/pyproject-fmt to v2.21.2 * upper bound toml-fmt-common till fixed Signed-off-by: gruebel <anton.gruebel@gmail.com> --------- Signed-off-by: gruebel <anton.gruebel@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel <anton.gruebel@gmail.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update astral-sh/setup-uv action to v8 (#603) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update codecov/codecov-action action to v6 (#604) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update googleapis/release-please-action action to v5 (#605) * chore(deps): update googleapis/release-please-action action to v5 * fix config Signed-off-by: gruebel <anton.gruebel@gmail.com> --------- Signed-off-by: gruebel <anton.gruebel@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel <anton.gruebel@gmail.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update pre-commit hook pre-commit/mirrors-mypy to v2 (#606) * chore(deps): update pre-commit hook pre-commit/mirrors-mypy to v2 * update config Signed-off-by: gruebel <anton.gruebel@gmail.com> --------- Signed-off-by: gruebel <anton.gruebel@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel <anton.gruebel@gmail.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update dependency prek to >=0.4.3,<0.5.0 (#607) * chore(deps): update dependency prek to >=0.4.3,<0.5.0 * adjust CI Signed-off-by: gruebel <anton.gruebel@gmail.com> --------- Signed-off-by: gruebel <anton.gruebel@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel <anton.gruebel@gmail.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * feat!: make set_provider non-blocking, add set_provider_and_wait (#595) * feat!: make set_provider non-blocking, add set_provider_and_wait Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> * fix: ruff format signature collapse in api.py Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> * fix: use threading.Event in error event test to avoid flaky busy-wait Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> * fixup: pr feedback and additional checks Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * fix: check active registration in stale-init guard, not _provider_status Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> * fixup: edge shutdown race Signed-off-by: Todd Baert <todd.baert@dynatrace.com> --------- Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * fix: isolate provider event handler dispatch (#599) * Isolate provider event handlers Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * Address event handler review feedback Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * test: cover event dispatch noop path Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * fixup: drain executor at exit and relax non-blocking test timing margin Signed-off-by: Todd Baert <todd.baert@dynatrace.com> --------- Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * test: fix flaky event handler test (#609) fix flaky event handler test Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.15.15 (#608) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * chore(main): release 0.10.0 (#602) * chore(main): release 0.10.0 Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> * docs: clarify non-blocking set_provider behavior in changelog Signed-off-by: Todd Baert <todd.baert@dynatrace.com> --------- Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com> Signed-off-by: gruebel <anton.gruebel@gmail.com> * fix CR comments Signed-off-by: gruebel <anton.gruebel@gmail.com> * cleanup Signed-off-by: gruebel <anton.gruebel@gmail.com> --------- Signed-off-by: gruebel <anton.gruebel@gmail.com> Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com> Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Co-authored-by: Jonathan Norris <jonathan.norris@dynatrace.com> Co-authored-by: Anton Grübel <anton.gruebel@gmail.com> Co-authored-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Nguyen Cat Luong <pkiphone.anhluong@gmail.com> Co-authored-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com>
1 parent da485f3 commit 79bb01b

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

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: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+
set_transaction_context,
15+
)
16+
17+
18+
class RetrievableContextProvider(AbstractProvider):
19+
"""Stores the last merged evaluation context it was asked to resolve."""
20+
21+
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
22+
super().__init__(*args, **kwargs)
23+
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+
96+
provider = RetrievableContextProvider()
97+
api.set_provider(provider)
98+
context.provider = provider
99+
context.client = api.get_client()
100+
context.invocation_context = EvaluationContext()
101+
context.before_hook_context = EvaluationContext()
102+
context._merging_initialized = True
103+
104+
105+
@given("a stable provider with retrievable context is registered")
106+
def step_impl_retrievable_provider(context: typing.Any) -> None:
107+
context._merging_initialized = False
108+
_ensure_state(context)
109+
110+
111+
def _add_entry_at_level(
112+
context: typing.Any, level: str, key: str, value: typing.Any
113+
) -> None:
114+
if level not in _LEVELS:
115+
raise ValueError(f"Unknown level: {level!r}")
116+
117+
new_entry = (
118+
EvaluationContext(targeting_key=value)
119+
if key == "targeting_key"
120+
else EvaluationContext(attributes={key: value})
121+
)
122+
if level == "API":
123+
api.set_evaluation_context(api.get_evaluation_context().merge(new_entry))
124+
elif level == "Transaction":
125+
set_transaction_context(api.get_transaction_context().merge(new_entry))
126+
elif level == "Client":
127+
context.client.context = context.client.context.merge(new_entry)
128+
elif level == "Invocation":
129+
context.invocation_context = context.invocation_context.merge(new_entry)
130+
elif level == "Before Hooks":
131+
context.before_hook_context = context.before_hook_context.merge(new_entry)
132+
133+
134+
@given(
135+
'A context entry with key "{key}" and value "{value}" is added to the '
136+
'"{level}" level'
137+
)
138+
def step_impl_add_entry(context: typing.Any, key: str, value: str, level: str) -> None:
139+
_add_entry_at_level(context, level, key, value)
140+
141+
142+
@given("A table with levels of increasing precedence")
143+
def step_impl_levels_table(context: typing.Any) -> None:
144+
# The feature table is a single-column list of levels. Behave treats the
145+
# first row as the heading, so recombine heading + body rows.
146+
levels = [context.table.headings[0]]
147+
levels.extend(row[0] for row in context.table.rows)
148+
context.precedence_levels = levels
149+
150+
151+
@given(
152+
'Context entries for each level from API level down to the "{level}" level, '
153+
'with key "{key}" and value "{value}"'
154+
)
155+
def step_impl_entries_down_to(
156+
context: typing.Any, level: str, key: str, value: str
157+
) -> None:
158+
levels = context.precedence_levels
159+
if level not in levels:
160+
raise ValueError(f"Level {level!r} not in precedence table {levels!r}")
161+
for current_level in levels:
162+
_add_entry_at_level(context, current_level, key, value)
163+
if current_level == level:
164+
break
165+
166+
167+
@when("Some flag was evaluated")
168+
def step_impl_evaluate(context: typing.Any) -> None:
169+
context.client.add_hooks(
170+
[(_BeforeHookContextInjector(context.before_hook_context))]
171+
)
172+
context.client.get_boolean_details("some-flag", False, context.invocation_context)
173+
174+
175+
@then('The merged context contains an entry with key "{key}" and value "{value}"')
176+
def step_impl_merged_contains(context: typing.Any, key: str, value: str) -> None:
177+
assert context.provider.last_context is not None, (
178+
"provider did not receive an evaluation context"
179+
)
180+
last_context = context.provider.last_context
181+
actual_value = (
182+
last_context.targeting_key
183+
if key == "targeting_key"
184+
else last_context.attributes.get(key)
185+
)
186+
assert actual_value == value, f"expected {key!r}={value!r}, got {actual_value!r}"

0 commit comments

Comments
 (0)