Skip to content

Commit b8e0b54

Browse files
authored
feat: events (#357)
1 parent 8b9eb24 commit b8e0b54

File tree

5 files changed

+224
-17
lines changed

5 files changed

+224
-17
lines changed

UnleashClient/__init__.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@
2424
SDK_NAME,
2525
SDK_VERSION,
2626
)
27-
from UnleashClient.events import UnleashEvent, UnleashEventType
27+
from UnleashClient.events import (
28+
BaseEvent,
29+
UnleashEvent,
30+
UnleashEventType,
31+
UnleashReadyEvent,
32+
)
2833
from UnleashClient.loader import load_features
2934
from UnleashClient.periodic_tasks import (
3035
aggregate_and_send_metrics,
@@ -46,6 +51,37 @@
4651
]
4752

4853

54+
def build_ready_callback(
55+
event_callback: Optional[Callable[[BaseEvent], None]] = None,
56+
) -> Optional[Callable]:
57+
"""
58+
Builds a callback function that can be used to notify when the Unleash client is ready.
59+
"""
60+
61+
if not event_callback:
62+
return None
63+
64+
already_fired = False
65+
66+
def ready_callback() -> None:
67+
"""
68+
Callback function to notify that the Unleash client is ready.
69+
This will only call the event_callback once.
70+
"""
71+
nonlocal already_fired
72+
if already_fired:
73+
return
74+
if event_callback:
75+
event = UnleashReadyEvent(
76+
event_type=UnleashEventType.READY,
77+
event_id=uuid.uuid4(),
78+
)
79+
already_fired = True
80+
event_callback(event)
81+
82+
return ready_callback
83+
84+
4985
# pylint: disable=dangerous-default-value
5086
class UnleashClient:
5187
"""
@@ -99,7 +135,7 @@ def __init__(
99135
scheduler: Optional[BaseScheduler] = None,
100136
scheduler_executor: Optional[str] = None,
101137
multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN,
102-
event_callback: Optional[Callable[[UnleashEvent], None]] = None,
138+
event_callback: Optional[Callable[[BaseEvent], None]] = None,
103139
) -> None:
104140
custom_headers = custom_headers or {}
105141
custom_options = custom_options or {}
@@ -132,6 +168,7 @@ def __init__(
132168
self.unleash_project_name = project_name
133169
self.unleash_verbose_log_level = verbose_log_level
134170
self.unleash_event_callback = event_callback
171+
self._ready_callback = build_ready_callback(event_callback)
135172

136173
self._do_instance_check(multiple_instance_mode)
137174

@@ -283,12 +320,15 @@ def initialize_client(self, fetch_toggles: bool = True) -> None:
283320
"request_timeout": self.unleash_request_timeout,
284321
"request_retries": self.unleash_request_retries,
285322
"project": self.unleash_project_name,
323+
"event_callback": self.unleash_event_callback,
324+
"ready_callback": self._ready_callback,
286325
}
287326
job_func: Callable = fetch_and_load_features
288327
else:
289328
job_args = {
290329
"cache": self.cache,
291330
"engine": self.engine,
331+
"ready_callback": self._ready_callback,
292332
}
293333
job_func = load_features
294334

UnleashClient/events.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import dataclass
22
from enum import Enum
3+
from json import loads
34
from typing import Optional
45
from uuid import UUID
56

@@ -11,17 +12,51 @@ class UnleashEventType(Enum):
1112

1213
FEATURE_FLAG = "feature_flag"
1314
VARIANT = "variant"
15+
FETCHED = "fetched"
16+
READY = "ready"
1417

1518

1619
@dataclass
17-
class UnleashEvent:
20+
class BaseEvent:
1821
"""
19-
Dataclass capturing information from an Unleash feature flag or variant check.
22+
Base event type for all events in the Unleash client.
2023
"""
2124

2225
event_type: UnleashEventType
2326
event_id: UUID
27+
28+
29+
@dataclass
30+
class UnleashEvent(BaseEvent):
31+
"""
32+
Dataclass capturing information from an Unleash feature flag or variant check.
33+
"""
34+
2435
context: dict
2536
enabled: bool
2637
feature_name: str
2738
variant: Optional[str] = ""
39+
40+
41+
@dataclass
42+
class UnleashReadyEvent(BaseEvent):
43+
"""
44+
Event indicating that the Unleash client is ready.
45+
"""
46+
47+
pass
48+
49+
50+
@dataclass
51+
class UnleashFetchedEvent(BaseEvent):
52+
"""
53+
Event indicating that the Unleash client has fetched feature flags.
54+
"""
55+
56+
raw_features: str
57+
58+
@property
59+
def features(self) -> dict:
60+
if not hasattr(self, "_parsed_payload"):
61+
self._parsed_payload = loads(self.raw_features)["features"]
62+
return self._parsed_payload

UnleashClient/loader.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Callable, Optional
2+
13
from yggdrasil_engine.engine import UnleashEngine
24

35
from UnleashClient.cache import BaseCache
@@ -8,6 +10,7 @@
810
def load_features(
911
cache: BaseCache,
1012
engine: UnleashEngine,
13+
ready_callback: Optional[Callable] = None,
1114
) -> None:
1215
"""
1316
Caching
@@ -27,6 +30,8 @@ def load_features(
2730

2831
try:
2932
warnings = engine.take_state(feature_provisioning)
33+
if ready_callback:
34+
ready_callback()
3035
if warnings:
3136
LOGGER.warning(
3237
"Some features were not able to be parsed correctly, they may not evaluate as expected"

UnleashClient/periodic_tasks/fetch_and_load.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from typing import Optional
1+
import uuid
2+
from typing import Callable, Optional
23

34
from yggdrasil_engine.engine import UnleashEngine
45

56
from UnleashClient.api import get_feature_toggles
67
from UnleashClient.cache import BaseCache
78
from UnleashClient.constants import ETAG, FEATURES_URL
9+
from UnleashClient.events import UnleashEventType, UnleashFetchedEvent
810
from UnleashClient.loader import load_features
911
from UnleashClient.utils import LOGGER
1012

@@ -20,6 +22,8 @@ def fetch_and_load_features(
2022
request_retries: int,
2123
engine: UnleashEngine,
2224
project: Optional[str] = None,
25+
event_callback: Optional[Callable] = None,
26+
ready_callback: Optional[Callable] = None,
2327
) -> None:
2428
(state, etag) = get_feature_toggles(
2529
url,
@@ -44,3 +48,14 @@ def fetch_and_load_features(
4448
cache.set(ETAG, etag)
4549

4650
load_features(cache, engine)
51+
52+
if state:
53+
if event_callback:
54+
event = UnleashFetchedEvent(
55+
event_type=UnleashEventType.FETCHED,
56+
event_id=uuid.uuid4(),
57+
raw_features=state,
58+
)
59+
event_callback(event)
60+
if ready_callback:
61+
ready_callback()

tests/unit_tests/test_client.py

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -891,28 +891,24 @@ def test_multiple_instances_are_unique_on_api_key(caplog):
891891
@responses.activate
892892
def test_signals_feature_flag(cache):
893893
# Set up API
894-
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
895894
responses.add(
896895
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
897896
)
898-
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
897+
flag_event = None
898+
variant_event = None
899899

900900
# Set up signals
901901
send_data = signal("send-data")
902902

903903
@send_data.connect
904904
def receive_data(sender, **kw):
905-
print("Caught signal from %r, data %r" % (sender, kw))
906-
905+
# variant_event
907906
if kw["data"].event_type == UnleashEventType.FEATURE_FLAG:
908-
assert kw["data"].feature_name == "testFlag"
909-
assert kw["data"].enabled
907+
nonlocal flag_event
908+
flag_event = kw["data"]
910909
elif kw["data"].event_type == UnleashEventType.VARIANT:
911-
assert kw["data"].feature_name == "testVariations"
912-
assert kw["data"].enabled
913-
assert kw["data"].variant == "VarA"
914-
915-
raise Exception("Random!")
910+
nonlocal variant_event
911+
variant_event = kw["data"]
916912

917913
def example_callback(event: UnleashEvent):
918914
send_data.send("anonymous", data=event)
@@ -922,7 +918,8 @@ def example_callback(event: UnleashEvent):
922918
URL,
923919
APP_NAME,
924920
refresh_interval=REFRESH_INTERVAL,
925-
metrics_interval=METRICS_INTERVAL,
921+
disable_registration=True,
922+
disable_metrics=True,
926923
cache=cache,
927924
event_callback=example_callback,
928925
)
@@ -935,6 +932,121 @@ def example_callback(event: UnleashEvent):
935932
variant = unleash_client.get_variant("testVariations", context={"userId": "2"})
936933
assert variant["name"] == "VarA"
937934

935+
assert flag_event.feature_name == "testFlag"
936+
assert flag_event.enabled
937+
938+
assert variant_event.feature_name == "testVariations"
939+
assert variant_event.enabled
940+
assert variant_event.variant == "VarA"
941+
942+
943+
@responses.activate
944+
def test_fetch_signal(cache):
945+
# Set up API
946+
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
947+
responses.add(
948+
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
949+
)
950+
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
951+
trapped_event = None
952+
953+
# Set up signals
954+
send_data = signal("send-data")
955+
956+
@send_data.connect
957+
def receive_data(sender, **kw):
958+
959+
if kw["data"].event_type == UnleashEventType.FETCHED:
960+
nonlocal trapped_event
961+
trapped_event = kw["data"]
962+
963+
def example_callback(event: UnleashEvent):
964+
send_data.send("anonymous", data=event)
965+
966+
# Set up Unleash
967+
unleash_client = UnleashClient(
968+
URL,
969+
APP_NAME,
970+
refresh_interval=REFRESH_INTERVAL,
971+
metrics_interval=METRICS_INTERVAL,
972+
cache=cache,
973+
event_callback=example_callback,
974+
)
975+
976+
# Create Unleash client and check initial load
977+
unleash_client.initialize_client()
978+
time.sleep(1)
979+
980+
assert trapped_event.features[0]["name"] == "testFlag"
981+
982+
983+
@responses.activate
984+
def test_ready_signal(cache):
985+
responses.add(
986+
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
987+
)
988+
trapped_events = 0
989+
990+
# Set up signals
991+
send_data = signal("send-data")
992+
993+
@send_data.connect
994+
def receive_data(sender, **kw):
995+
if kw["data"].event_type == UnleashEventType.READY:
996+
nonlocal trapped_events
997+
trapped_events += 1
998+
999+
def example_callback(event: UnleashEvent):
1000+
send_data.send("anonymous", data=event)
1001+
1002+
unleash_client = UnleashClient(
1003+
URL,
1004+
APP_NAME,
1005+
refresh_interval=1, # minimum interval is 1 second
1006+
disable_metrics=True,
1007+
disable_registration=True,
1008+
cache=cache,
1009+
event_callback=example_callback,
1010+
)
1011+
1012+
unleash_client.initialize_client()
1013+
time.sleep(2)
1014+
1015+
assert trapped_events == 1
1016+
1017+
1018+
def test_ready_signal_works_with_bootstrapping():
1019+
cache = FileCache("MOCK_CACHE")
1020+
cache.bootstrap_from_dict(MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE)
1021+
1022+
trapped_events = 0
1023+
1024+
# Set up signals
1025+
send_data = signal("send-data")
1026+
1027+
@send_data.connect
1028+
def receive_data(sender, **kw):
1029+
if kw["data"].event_type == UnleashEventType.READY:
1030+
nonlocal trapped_events
1031+
trapped_events += 1
1032+
1033+
def example_callback(event: UnleashEvent):
1034+
send_data.send("anonymous", data=event)
1035+
1036+
unleash_client = UnleashClient(
1037+
url=URL,
1038+
app_name=APP_NAME,
1039+
cache=cache,
1040+
disable_metrics=True,
1041+
disable_registration=True,
1042+
event_callback=example_callback,
1043+
)
1044+
1045+
unleash_client.initialize_client(fetch_toggles=False)
1046+
time.sleep(1)
1047+
1048+
assert trapped_events == 1
1049+
9381050

9391051
def test_context_handles_numerics():
9401052
cache = FileCache("MOCK_CACHE")

0 commit comments

Comments
 (0)