Skip to content
This repository was archived by the owner on Nov 8, 2024. It is now read-only.

Commit 70595d1

Browse files
lisaahaarsilv
andauthored
Cast provided categorical attributes to strings. Still log and return variation if no actions (#59)
* Return the default value for a bandit flag if no actions. Cast provided categorical attributes to strings. * Formatting * update for revisted zero-action strategy * return variation if that has been computed * changes from self-review of PR * test for bandit logger error * fix flake8 lintint errors * black formatter * return type ignore hint * fix type hint * feedback from PR * update type in comment per PR suggestion --------- Co-authored-by: Aaron Silverman <[email protected]>
1 parent ea46eb1 commit 70595d1

File tree

3 files changed

+194
-81
lines changed

3 files changed

+194
-81
lines changed

eppo_client/client.py

Lines changed: 108 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,20 @@ def get_bandit_action(
256256
Args:
257257
flag_key (str): The feature flag key that contains the bandit as one of the variations.
258258
subject_key (str): The key identifying the subject.
259-
subject_context (ActionContexts | ActionAttributes): The subject context.
259+
subject_context (Union[ContextAttributes, Attributes]): The subject context.
260260
If supplying an ActionAttributes, it gets converted to an ActionContexts instance
261-
actions (ActionContexts | ActionAttributes): The dictionary that maps action keys
261+
actions (Union[ActionContexts, ActionAttributes]): The dictionary that maps action keys
262262
to their context of actions with their contexts.
263263
If supplying an ActionAttributes, it gets converted to an ActionContexts instance.
264-
default (str): The default variation to use if the subject is not part of the bandit.
264+
default (str): The default variation to use if an error is encountered retrieving the
265+
assigned variation.
265266
266267
Returns:
267268
BanditResult: The result containing either the bandit action if the subject is part of the bandit,
268269
or the assignment if they are not. The BanditResult includes:
269270
- variation (str): The assignment key indicating the subject's variation.
270-
- action (str): The key of the selected action if the subject is part of the bandit.
271+
- action (Optional[str]): The key of the selected action if the subject was assigned one
272+
by the bandit.
271273
272274
Example:
273275
result = client.get_bandit_action(
@@ -286,102 +288,116 @@ def get_bandit_action(
286288
},
287289
"default"
288290
)
289-
if result.action is None:
290-
do_variation(result.variation)
291+
if result.action:
292+
do_action(result.variation)
291293
else:
292-
do_action(result.action)
294+
do_status_quo()
293295
"""
296+
variation = default
297+
action = None
294298
try:
295-
return self.get_bandit_action_detail(
299+
subject_attributes = convert_context_attributes_to_attributes(
300+
subject_context
301+
)
302+
303+
# first, get experiment assignment
304+
variation = self.get_string_assignment(
296305
flag_key,
297306
subject_key,
298-
subject_context,
299-
actions,
307+
subject_attributes,
300308
default,
301309
)
310+
311+
if variation in self.get_bandit_keys():
312+
# next, if assigned a bandit, get the selected action
313+
action = self.evaluate_bandit_action(
314+
flag_key,
315+
variation, # for now, we assume the variation value is always equal to the bandit key
316+
subject_key,
317+
subject_context,
318+
actions,
319+
)
302320
except Exception as e:
303321
if self.__is_graceful_mode:
304322
logger.error("[Eppo SDK] Error getting bandit action: " + str(e))
305-
return BanditResult(default, None)
306-
raise e
323+
else:
324+
raise e
325+
326+
return BanditResult(variation, action)
307327

308-
def get_bandit_action_detail(
328+
def evaluate_bandit_action(
309329
self,
310330
flag_key: str,
331+
bandit_key: str,
311332
subject_key: str,
312333
subject_context: Union[ContextAttributes, Attributes],
313334
actions: Union[ActionContexts, ActionAttributes],
314-
default: str,
315-
) -> BanditResult:
316-
subject_attributes = convert_subject_context_to_attributes(subject_context)
317-
action_contexts = convert_actions_to_action_contexts(actions)
335+
) -> Union[str, None]:
336+
# if no actions are given--a valid use case--return the variation with no action
337+
if len(actions) == 0:
338+
return None
318339

319-
# get experiment assignment
320-
# ignoring type because Dict[str, str] satisfies Dict[str, str | ...] but mypy does not understand
321-
variation = self.get_string_assignment(
322-
flag_key,
323-
subject_key,
324-
subject_attributes.categorical_attributes
325-
| subject_attributes.numeric_attributes, # type: ignore
326-
default,
327-
)
328-
329-
# if the variation is not the bandit key, then the subject is not allocated in the bandit
330-
if variation not in self.get_bandit_keys():
331-
return BanditResult(variation, None)
332-
333-
# for now, assume that the variation is equal to the bandit key
334-
bandit_data = self.__config_requestor.get_bandit_model(variation)
340+
bandit_data = self.__config_requestor.get_bandit_model(bandit_key)
335341

336342
if not bandit_data:
337343
logger.warning(
338344
f"[Eppo SDK] No assigned action. Bandit not found for flag: {flag_key}"
339345
)
340-
return BanditResult(variation, None)
346+
return None
347+
348+
subject_context_attributes = convert_attributes_to_context_attributes(
349+
subject_context
350+
)
351+
action_contexts = convert_actions_to_action_contexts(actions)
341352

342353
evaluation = self.__bandit_evaluator.evaluate_bandit(
343354
flag_key,
344355
subject_key,
345-
subject_attributes,
356+
subject_context_attributes,
346357
action_contexts,
347358
bandit_data.bandit_model_data,
348359
)
349360

350361
# log bandit action
351-
bandit_event = {
352-
"flagKey": flag_key,
353-
"banditKey": bandit_data.bandit_key,
354-
"subject": subject_key,
355-
"action": evaluation.action_key if evaluation else None,
356-
"actionProbability": evaluation.action_weight if evaluation else None,
357-
"optimalityGap": evaluation.optimality_gap if evaluation else None,
358-
"modelVersion": bandit_data.bandit_model_version if evaluation else None,
359-
"timestamp": datetime.datetime.utcnow().isoformat(),
360-
"subjectNumericAttributes": (
361-
subject_attributes.numeric_attributes
362-
if evaluation.subject_attributes
363-
else {}
364-
),
365-
"subjectCategoricalAttributes": (
366-
subject_attributes.categorical_attributes
367-
if evaluation.subject_attributes
368-
else {}
369-
),
370-
"actionNumericAttributes": (
371-
evaluation.action_attributes.numeric_attributes
372-
if evaluation.action_attributes
373-
else {}
374-
),
375-
"actionCategoricalAttributes": (
376-
evaluation.action_attributes.categorical_attributes
377-
if evaluation.action_attributes
378-
else {}
379-
),
380-
"metaData": {"sdkLanguage": "python", "sdkVersion": __version__},
381-
}
382-
self.__assignment_logger.log_bandit_action(bandit_event)
362+
try:
363+
bandit_event = {
364+
"flagKey": flag_key,
365+
"banditKey": bandit_data.bandit_key,
366+
"subject": subject_key,
367+
"action": evaluation.action_key if evaluation else None,
368+
"actionProbability": evaluation.action_weight if evaluation else None,
369+
"optimalityGap": evaluation.optimality_gap if evaluation else None,
370+
"modelVersion": (
371+
bandit_data.bandit_model_version if evaluation else None
372+
),
373+
"timestamp": datetime.datetime.utcnow().isoformat(),
374+
"subjectNumericAttributes": (
375+
subject_context_attributes.numeric_attributes
376+
if evaluation.subject_attributes
377+
else {}
378+
),
379+
"subjectCategoricalAttributes": (
380+
subject_context_attributes.categorical_attributes
381+
if evaluation.subject_attributes
382+
else {}
383+
),
384+
"actionNumericAttributes": (
385+
evaluation.action_attributes.numeric_attributes
386+
if evaluation.action_attributes
387+
else {}
388+
),
389+
"actionCategoricalAttributes": (
390+
evaluation.action_attributes.categorical_attributes
391+
if evaluation.action_attributes
392+
else {}
393+
),
394+
"metaData": {"sdkLanguage": "python", "sdkVersion": __version__},
395+
}
396+
self.__assignment_logger.log_bandit_action(bandit_event)
397+
except Exception as e:
398+
logger.warn("[Eppo SDK] Error logging bandit event: " + str(e))
383399

384-
return BanditResult(variation, evaluation.action_key if evaluation else None)
400+
return evaluation.action_key
385401

386402
def get_flag_keys(self):
387403
"""
@@ -406,6 +422,9 @@ def get_bandit_keys(self):
406422
"""
407423
return self.__config_requestor.get_bandit_keys()
408424

425+
def set_is_graceful_mode(self, is_graceful_mode: bool):
426+
self.__is_graceful_mode = is_graceful_mode
427+
409428
def is_initialized(self):
410429
"""
411430
Returns True if the client has successfully initialized
@@ -443,18 +462,33 @@ def check_value_type_match(
443462
return False
444463

445464

446-
def convert_subject_context_to_attributes(
465+
def convert_context_attributes_to_attributes(
466+
subject_context: Union[ContextAttributes, Attributes]
467+
) -> Attributes:
468+
if isinstance(subject_context, dict):
469+
return subject_context
470+
471+
# ignoring type because Dict[str, str] satisfies Dict[str, str | ...] but mypy does not understand
472+
return subject_context.numeric_attributes | subject_context.categorical_attributes # type: ignore
473+
474+
475+
def convert_attributes_to_context_attributes(
447476
subject_context: Union[ContextAttributes, Attributes]
448477
) -> ContextAttributes:
449478
if isinstance(subject_context, dict):
450479
return ContextAttributes.from_dict(subject_context)
451-
return subject_context
480+
481+
stringified_categorical_attributes = {
482+
key: str(value) for key, value in subject_context.categorical_attributes.items()
483+
}
484+
485+
return ContextAttributes(
486+
numeric_attributes=subject_context.numeric_attributes,
487+
categorical_attributes=stringified_categorical_attributes,
488+
)
452489

453490

454491
def convert_actions_to_action_contexts(
455492
actions: Union[ActionContexts, ActionAttributes]
456493
) -> ActionContexts:
457-
return {
458-
k: ContextAttributes.from_dict(v) if isinstance(v, dict) else v
459-
for k, v in actions.items()
460-
}
494+
return {k: convert_attributes_to_context_attributes(v) for k, v in actions.items()}

eppo_client/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Note to developers: When ready to bump to 4.0, please change
22
# the `POLL_INTERVAL_SECONDS` constant in `eppo_client/constants.py`
33
# to 30 seconds to match the behavior of the other server SDKs.
4-
__version__ = "3.5.0"
4+
__version__ = "3.5.1"

test/client_bandit_test.py

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import os
77
from time import sleep
88
from typing import Dict, List
9-
from eppo_client.bandit import BanditResult, ContextAttributes
9+
from unittest.mock import patch
10+
from eppo_client.bandit import BanditEvaluator, BanditResult, ContextAttributes
1011

1112
import httpretty # type: ignore
1213
import pytest
@@ -81,15 +82,24 @@ def init_fixture():
8182
httpretty.reset()
8283

8384

85+
@pytest.fixture(autouse=True)
86+
def clear_event_arrays():
87+
# Reset graceful mode to off
88+
get_instance().set_is_graceful_mode(False)
89+
# Clear captured logger events
90+
mock_assignment_logger.assignment_events.clear()
91+
mock_assignment_logger.bandit_events.clear()
92+
93+
8494
def test_is_initialized():
8595
client = get_instance()
8696
assert client.is_initialized(), "Client should be initialized"
8797

8898

89-
def test_get_bandit_action_bandit_does_not_exist():
99+
def test_get_bandit_action_flag_not_exist():
90100
client = get_instance()
91101
result = client.get_bandit_action(
92-
"nonexistent_bandit",
102+
"nonexistent_flag",
93103
"subject_key",
94104
DEFAULT_SUBJECT_ATTRIBUTES,
95105
{},
@@ -98,12 +108,52 @@ def test_get_bandit_action_bandit_does_not_exist():
98108
assert result == BanditResult("default_variation", None)
99109

100110

101-
def test_get_bandit_action_flag_without_bandit():
111+
def test_get_bandit_action_flag_has_no_bandit():
102112
client = get_instance()
103113
result = client.get_bandit_action(
104-
"a_flag", "subject_key", DEFAULT_SUBJECT_ATTRIBUTES, {}, "default_variation"
114+
"non_bandit_flag",
115+
"subject_key",
116+
DEFAULT_SUBJECT_ATTRIBUTES,
117+
{},
118+
"default_variation",
105119
)
106-
assert result == BanditResult("default_variation", None)
120+
assert result == BanditResult("control", None)
121+
122+
123+
@patch.object(
124+
BanditEvaluator, "evaluate_bandit", side_effect=Exception("Mocked Exception")
125+
)
126+
def test_get_bandit_action_bandit_error(mock_bandit_evaluator):
127+
client = get_instance()
128+
client.set_is_graceful_mode(True)
129+
actions = {
130+
"adidas": ContextAttributes(
131+
numeric_attributes={"discount": 0.1},
132+
categorical_attributes={"from": "germany"},
133+
),
134+
"nike": ContextAttributes(
135+
numeric_attributes={"discount": 0.2}, categorical_attributes={"from": "usa"}
136+
),
137+
}
138+
139+
result = client.get_bandit_action(
140+
"banner_bandit_flag_uk_only",
141+
"alice",
142+
DEFAULT_SUBJECT_ATTRIBUTES,
143+
actions,
144+
"default_variation",
145+
)
146+
assert result.variation == "banner_bandit"
147+
assert result.action is None
148+
149+
# testing assignment logger
150+
assignment_log_statement = mock_assignment_logger.assignment_events[-1]
151+
assert assignment_log_statement["featureFlag"] == "banner_bandit_flag_uk_only"
152+
assert assignment_log_statement["variation"] == "banner_bandit"
153+
assert assignment_log_statement["subject"] == "alice"
154+
155+
# testing bandit logger
156+
assert len(mock_assignment_logger.bandit_events) == 0
107157

108158

109159
def test_get_bandit_action_with_subject_attributes():
@@ -163,6 +213,35 @@ def test_get_bandit_action_with_subject_attributes():
163213
)
164214

165215

216+
@patch.object(
217+
MockAssignmentLogger, "log_bandit_action", side_effect=Exception("Mocked Exception")
218+
)
219+
def test_get_bandit_action_bandit_logger_error(patched_mock_assignment_logger):
220+
client = get_instance()
221+
actions = {
222+
"adidas": ContextAttributes(
223+
numeric_attributes={"discount": 0.1},
224+
categorical_attributes={"from": "germany"},
225+
),
226+
"nike": ContextAttributes(
227+
numeric_attributes={"discount": 0.2}, categorical_attributes={"from": "usa"}
228+
),
229+
}
230+
result = client.get_bandit_action(
231+
"banner_bandit_flag_uk_only",
232+
"alice",
233+
DEFAULT_SUBJECT_ATTRIBUTES,
234+
actions,
235+
"default_variation",
236+
)
237+
assert result.variation == "banner_bandit"
238+
assert result.action in ["adidas", "nike"]
239+
240+
# assignment should have still been logged
241+
assert len(mock_assignment_logger.assignment_events) == 1
242+
assert len(mock_assignment_logger.bandit_events) == 0
243+
244+
166245
@pytest.mark.parametrize("test_case", test_data)
167246
def test_bandit_generic_test_cases(test_case):
168247
client = get_instance()

0 commit comments

Comments
 (0)