Skip to content

Commit 5acd6a6

Browse files
authored
refactor: improve Hook Hints typing (#285)
* improve Hook Hints typing Signed-off-by: gruebel <[email protected]> * ignore lint issue for this line Signed-off-by: gruebel <[email protected]> * exclude TYPE_CHECKING from coverage report Signed-off-by: gruebel <[email protected]> --------- Signed-off-by: gruebel <[email protected]>
1 parent 141858d commit 5acd6a6

File tree

4 files changed

+61
-25
lines changed

4 files changed

+61
-25
lines changed

openfeature/flag_evaluation.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from openfeature._backports.strenum import StrEnum
77
from openfeature.exception import ErrorCode
88

9-
if typing.TYPE_CHECKING: # resolves a circular dependency in type annotations
10-
from openfeature.hook import Hook
9+
if typing.TYPE_CHECKING: # pragma: no cover
10+
# resolves a circular dependency in type annotations
11+
from openfeature.hook import Hook, HookHints
1112

1213

1314
class FlagType(StrEnum):
@@ -48,7 +49,7 @@ class FlagEvaluationDetails(typing.Generic[T_co]):
4849
@dataclass
4950
class FlagEvaluationOptions:
5051
hooks: typing.List[Hook] = field(default_factory=list)
51-
hook_hints: dict = field(default_factory=dict)
52+
hook_hints: HookHints = field(default_factory=dict)
5253

5354

5455
U_co = typing.TypeVar("U_co", covariant=True)

openfeature/hook/__init__.py

+42-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import typing
4-
from dataclasses import dataclass
4+
from datetime import datetime
55
from enum import Enum
66
from typing import TYPE_CHECKING
77

@@ -20,24 +20,53 @@ class HookType(Enum):
2020
ERROR = "error"
2121

2222

23-
@dataclass
2423
class HookContext:
25-
flag_key: str
26-
flag_type: FlagType
27-
default_value: typing.Any
28-
evaluation_context: EvaluationContext
29-
client_metadata: typing.Optional[ClientMetadata] = None
30-
provider_metadata: typing.Optional[Metadata] = None
24+
def __init__( # noqa: PLR0913
25+
self,
26+
flag_key: str,
27+
flag_type: FlagType,
28+
default_value: typing.Any,
29+
evaluation_context: EvaluationContext,
30+
client_metadata: typing.Optional[ClientMetadata] = None,
31+
provider_metadata: typing.Optional[Metadata] = None,
32+
):
33+
self.flag_key = flag_key
34+
self.flag_type = flag_type
35+
self.default_value = default_value
36+
self.evaluation_context = evaluation_context
37+
self.client_metadata = client_metadata
38+
self.provider_metadata = provider_metadata
3139

3240
def __setattr__(self, key: str, value: typing.Any) -> None:
33-
if hasattr(self, key) and key in ("flag_key", "flag_type", "default_value"):
41+
if hasattr(self, key) and key in (
42+
"flag_key",
43+
"flag_type",
44+
"default_value",
45+
"client_metadata",
46+
"provider_metadata",
47+
):
3448
raise AttributeError(f"Attribute {key!r} is immutable")
3549
super().__setattr__(key, value)
3650

3751

52+
# https://openfeature.dev/specification/sections/hooks/#requirement-421
53+
HookHints = typing.Mapping[
54+
str,
55+
typing.Union[
56+
bool,
57+
int,
58+
float,
59+
str,
60+
datetime,
61+
typing.List[typing.Any],
62+
typing.Dict[str, typing.Any],
63+
],
64+
]
65+
66+
3867
class Hook:
3968
def before(
40-
self, hook_context: HookContext, hints: dict
69+
self, hook_context: HookContext, hints: HookHints
4170
) -> typing.Optional[EvaluationContext]:
4271
"""
4372
Runs before flag is resolved.
@@ -54,7 +83,7 @@ def after(
5483
self,
5584
hook_context: HookContext,
5685
details: FlagEvaluationDetails[typing.Any],
57-
hints: dict,
86+
hints: HookHints,
5887
) -> None:
5988
"""
6089
Runs after a flag is resolved.
@@ -67,7 +96,7 @@ def after(
6796
pass
6897

6998
def error(
70-
self, hook_context: HookContext, exception: Exception, hints: dict
99+
self, hook_context: HookContext, exception: Exception, hints: HookHints
71100
) -> None:
72101
"""
73102
Run when evaluation encounters an error. Errors thrown will be swallowed.
@@ -78,7 +107,7 @@ def error(
78107
"""
79108
pass
80109

81-
def finally_after(self, hook_context: HookContext, hints: dict) -> None:
110+
def finally_after(self, hook_context: HookContext, hints: HookHints) -> None:
82111
"""
83112
Run after flag evaluation, including any error processing.
84113
This will always run. Errors will be swallowed.

openfeature/hook/hook_support.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from openfeature.evaluation_context import EvaluationContext
66
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType
7-
from openfeature.hook import Hook, HookContext, HookType
7+
from openfeature.hook import Hook, HookContext, HookHints, HookType
88

99
logger = logging.getLogger("openfeature")
1010

@@ -14,7 +14,7 @@ def error_hooks(
1414
hook_context: HookContext,
1515
exception: Exception,
1616
hooks: typing.List[Hook],
17-
hints: typing.Optional[typing.Mapping] = None,
17+
hints: typing.Optional[HookHints] = None,
1818
) -> None:
1919
kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints}
2020
_execute_hooks(
@@ -26,7 +26,7 @@ def after_all_hooks(
2626
flag_type: FlagType,
2727
hook_context: HookContext,
2828
hooks: typing.List[Hook],
29-
hints: typing.Optional[typing.Mapping] = None,
29+
hints: typing.Optional[HookHints] = None,
3030
) -> None:
3131
kwargs = {"hook_context": hook_context, "hints": hints}
3232
_execute_hooks(
@@ -39,7 +39,7 @@ def after_hooks(
3939
hook_context: HookContext,
4040
details: FlagEvaluationDetails[typing.Any],
4141
hooks: typing.List[Hook],
42-
hints: typing.Optional[typing.Mapping] = None,
42+
hints: typing.Optional[HookHints] = None,
4343
) -> None:
4444
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
4545
_execute_hooks_unchecked(
@@ -51,7 +51,7 @@ def before_hooks(
5151
flag_type: FlagType,
5252
hook_context: HookContext,
5353
hooks: typing.List[Hook],
54-
hints: typing.Optional[typing.Mapping] = None,
54+
hints: typing.Optional[HookHints] = None,
5555
) -> EvaluationContext:
5656
kwargs = {"hook_context": hook_context, "hints": hints}
5757
executed_hooks = _execute_hooks_unchecked(

tests/hook/test_hook_support.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ def test_hook_context_has_immutable_and_mutable_fields():
4040
4141
4.1.3 - The "flag key", "flag type", and "default value" properties MUST be immutable.
4242
4.1.4.1 - The evaluation context MUST be mutable only within the before hook.
43+
4.2.2.2 - The client "metadata" field in the "hook context" MUST be immutable.
44+
4.2.2.3 - The provider "metadata" field in the "hook context" MUST be immutable.
4345
"""
4446

4547
# Given
46-
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
48+
hook_context = HookContext(
49+
"flag_key", FlagType.BOOLEAN, True, EvaluationContext(), ClientMetadata("name")
50+
)
4751

4852
# When
4953
with pytest.raises(AttributeError):
@@ -52,18 +56,20 @@ def test_hook_context_has_immutable_and_mutable_fields():
5256
hook_context.flag_type = FlagType.STRING
5357
with pytest.raises(AttributeError):
5458
hook_context.default_value = "new_value"
59+
with pytest.raises(AttributeError):
60+
hook_context.client_metadata = ClientMetadata("new_name")
61+
with pytest.raises(AttributeError):
62+
hook_context.provider_metadata = Metadata("name")
5563

5664
hook_context.evaluation_context = EvaluationContext("targeting_key")
57-
hook_context.client_metadata = ClientMetadata("name")
58-
hook_context.provider_metadata = Metadata("name")
5965

6066
# Then
6167
assert hook_context.flag_key == "flag_key"
6268
assert hook_context.flag_type is FlagType.BOOLEAN
6369
assert hook_context.default_value is True
6470
assert hook_context.evaluation_context.targeting_key == "targeting_key"
6571
assert hook_context.client_metadata.name == "name"
66-
assert hook_context.provider_metadata.name == "name"
72+
assert hook_context.provider_metadata is None
6773

6874

6975
def test_error_hooks_run_error_method(mock_hook):

0 commit comments

Comments
 (0)