Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions UnleashClient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
SDK_NAME,
SDK_VERSION,
)
from UnleashClient.events import UnleashEvent, UnleashEventType
from UnleashClient.events import (
BaseEvent,
UnleashEvent,
UnleashEventType,
UnleashReadyEvent,
)
from UnleashClient.loader import load_features
from UnleashClient.periodic_tasks import (
aggregate_and_send_metrics,
Expand All @@ -46,6 +51,37 @@
]


def build_ready_callback(
event_callback: Optional[Callable[[BaseEvent], None]] = None,
) -> Optional[Callable]:
"""
Builds a callback function that can be used to notify when the Unleash client is ready.
"""

if not event_callback:
return None
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poor mans optional.map()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it makes sense to support this optional parameter. IMO, if you're going to call this function, you better have a callback. If you supply something that's not a callback then you should expect an error. But having this ability to call all functions with optional and checking if it's set seems a bit off.

On the other hand, if this function can be called from multiple places it centralizes the check in one single place in the code, so just picking your brain about it.


already_fired = False

def ready_callback() -> None:
"""
Callback function to notify that the Unleash client is ready.
This will only call the event_callback once.
"""
nonlocal already_fired
if already_fired:
return
if event_callback:
event = UnleashReadyEvent(
event_type=UnleashEventType.READY,
event_id=uuid.uuid4(),
)
already_fired = True
event_callback(event)

return ready_callback


# pylint: disable=dangerous-default-value
class UnleashClient:
"""
Expand Down Expand Up @@ -99,7 +135,7 @@ def __init__(
scheduler: Optional[BaseScheduler] = None,
scheduler_executor: Optional[str] = None,
multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN,
event_callback: Optional[Callable[[UnleashEvent], None]] = None,
event_callback: Optional[Callable[[BaseEvent], None]] = None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
event_callback: Optional[Callable[[BaseEvent], None]] = None,
event_callback: Callable[[BaseEvent], None] = lambda event: None,

Following my other comment, the alternative would be something like this.

) -> None:
custom_headers = custom_headers or {}
custom_options = custom_options or {}
Expand Down Expand Up @@ -132,6 +168,7 @@ def __init__(
self.unleash_project_name = project_name
self.unleash_verbose_log_level = verbose_log_level
self.unleash_event_callback = event_callback
self._ready_callback = build_ready_callback(event_callback)

self._do_instance_check(multiple_instance_mode)

Expand Down Expand Up @@ -283,12 +320,15 @@ def initialize_client(self, fetch_toggles: bool = True) -> None:
"request_timeout": self.unleash_request_timeout,
"request_retries": self.unleash_request_retries,
"project": self.unleash_project_name,
"event_callback": self.unleash_event_callback,
"ready_callback": self._ready_callback,
}
job_func: Callable = fetch_and_load_features
else:
job_args = {
"cache": self.cache,
"engine": self.engine,
"ready_callback": self._ready_callback,
}
job_func = load_features

Expand Down
39 changes: 37 additions & 2 deletions UnleashClient/events.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from enum import Enum
from json import loads
from typing import Optional
from uuid import UUID

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

FEATURE_FLAG = "feature_flag"
VARIANT = "variant"
FETCHED = "fetched"
READY = "ready"


@dataclass
class UnleashEvent:
class BaseEvent:
"""
Dataclass capturing information from an Unleash feature flag or variant check.
Base event type for all events in the Unleash client.
"""

event_type: UnleashEventType
event_id: UUID


@dataclass
class UnleashEvent(BaseEvent):
"""
Dataclass capturing information from an Unleash feature flag or variant check.
"""

context: dict
enabled: bool
feature_name: str
variant: Optional[str] = ""


@dataclass
class UnleashReadyEvent(BaseEvent):
"""
Event indicating that the Unleash client is ready.
"""

pass


@dataclass
class UnleashFetchedEvent(BaseEvent):
"""
Event indicating that the Unleash client has fetched feature flags.
"""

raw_features: str

@property
def features(self) -> dict:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing is JSON deserialize, which is expensive. This gives us lazy, cached parse here which the cost is only incurred on demand

if not hasattr(self, "_parsed_payload"):
self._parsed_payload = loads(self.raw_features)["features"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoyingly this now becomes part of the public API.

Kinda struggled a little with choosing between only returning the features vs returning the whole response. Leaning ever so slightly in the direction of only returning the features since its less confusing for end users.

That being said, every time we've done that in the past (fetch features, bootstrap, load from backup), it's been a mistake that needs to be patched later. Open to being told this is similar but I can't see how right now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still a dict, I think it'd be different if this is an array because it's not easy to extend an array with other properties, but being a dict I think this gives enough flexibility. And I agree, returning less is better IMO

return self._parsed_payload
5 changes: 5 additions & 0 deletions UnleashClient/loader.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Callable, Optional

from yggdrasil_engine.engine import UnleashEngine

from UnleashClient.cache import BaseCache
Expand All @@ -8,6 +10,7 @@
def load_features(
cache: BaseCache,
engine: UnleashEngine,
ready_callback: Optional[Callable] = None,
) -> None:
"""
Caching
Expand All @@ -27,6 +30,8 @@ def load_features(

try:
warnings = engine.take_state(feature_provisioning)
if ready_callback:
ready_callback()
if warnings:
LOGGER.warning(
"Some features were not able to be parsed correctly, they may not evaluate as expected"
Expand Down
17 changes: 16 additions & 1 deletion UnleashClient/periodic_tasks/fetch_and_load.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from typing import Optional
import uuid
from typing import Callable, Optional

from yggdrasil_engine.engine import UnleashEngine

from UnleashClient.api import get_feature_toggles
from UnleashClient.cache import BaseCache
from UnleashClient.constants import ETAG, FEATURES_URL
from UnleashClient.events import UnleashEventType, UnleashFetchedEvent
from UnleashClient.loader import load_features
from UnleashClient.utils import LOGGER

Expand All @@ -20,6 +22,8 @@ def fetch_and_load_features(
request_retries: int,
engine: UnleashEngine,
project: Optional[str] = None,
event_callback: Optional[Callable] = None,
ready_callback: Optional[Callable] = None,
) -> None:
(state, etag) = get_feature_toggles(
url,
Expand All @@ -44,3 +48,14 @@ def fetch_and_load_features(
cache.set(ETAG, etag)

load_features(cache, engine)

if state:
if event_callback:
event = UnleashFetchedEvent(
event_type=UnleashEventType.FETCHED,
event_id=uuid.uuid4(),
raw_features=state,
)
event_callback(event)
if ready_callback:
ready_callback()
136 changes: 124 additions & 12 deletions tests/unit_tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,28 +891,24 @@ def test_multiple_instances_are_unique_on_api_key(caplog):
@responses.activate
def test_signals_feature_flag(cache):
# Set up API
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
responses.add(
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
)
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
flag_event = None
variant_event = None

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

@send_data.connect
def receive_data(sender, **kw):
print("Caught signal from %r, data %r" % (sender, kw))

# variant_event
if kw["data"].event_type == UnleashEventType.FEATURE_FLAG:
assert kw["data"].feature_name == "testFlag"
assert kw["data"].enabled
nonlocal flag_event
flag_event = kw["data"]
elif kw["data"].event_type == UnleashEventType.VARIANT:
assert kw["data"].feature_name == "testVariations"
assert kw["data"].enabled
assert kw["data"].variant == "VarA"

raise Exception("Random!")
nonlocal variant_event
variant_event = kw["data"]

def example_callback(event: UnleashEvent):
send_data.send("anonymous", data=event)
Expand All @@ -922,7 +918,8 @@ def example_callback(event: UnleashEvent):
URL,
APP_NAME,
refresh_interval=REFRESH_INTERVAL,
metrics_interval=METRICS_INTERVAL,
disable_registration=True,
disable_metrics=True,
cache=cache,
event_callback=example_callback,
)
Expand All @@ -935,6 +932,121 @@ def example_callback(event: UnleashEvent):
variant = unleash_client.get_variant("testVariations", context={"userId": "2"})
assert variant["name"] == "VarA"

assert flag_event.feature_name == "testFlag"
assert flag_event.enabled

assert variant_event.feature_name == "testVariations"
assert variant_event.enabled
assert variant_event.variant == "VarA"


@responses.activate
def test_fetch_signal(cache):
# Set up API
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
responses.add(
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
)
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
trapped_event = None

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

@send_data.connect
def receive_data(sender, **kw):

if kw["data"].event_type == UnleashEventType.FETCHED:
nonlocal trapped_event
trapped_event = kw["data"]

def example_callback(event: UnleashEvent):
send_data.send("anonymous", data=event)

# Set up Unleash
unleash_client = UnleashClient(
URL,
APP_NAME,
refresh_interval=REFRESH_INTERVAL,
metrics_interval=METRICS_INTERVAL,
cache=cache,
event_callback=example_callback,
)

# Create Unleash client and check initial load
unleash_client.initialize_client()
time.sleep(1)

assert trapped_event.features[0]["name"] == "testFlag"


@responses.activate
def test_ready_signal(cache):
responses.add(
responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200
)
trapped_events = 0

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

@send_data.connect
def receive_data(sender, **kw):
if kw["data"].event_type == UnleashEventType.READY:
nonlocal trapped_events
trapped_events += 1

def example_callback(event: UnleashEvent):
send_data.send("anonymous", data=event)

unleash_client = UnleashClient(
URL,
APP_NAME,
refresh_interval=1, # minimum interval is 1 second
disable_metrics=True,
disable_registration=True,
cache=cache,
event_callback=example_callback,
)

unleash_client.initialize_client()
time.sleep(2)

assert trapped_events == 1


def test_ready_signal_works_with_bootstrapping():
cache = FileCache("MOCK_CACHE")
cache.bootstrap_from_dict(MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE)

trapped_events = 0

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

@send_data.connect
def receive_data(sender, **kw):
if kw["data"].event_type == UnleashEventType.READY:
nonlocal trapped_events
trapped_events += 1

def example_callback(event: UnleashEvent):
send_data.send("anonymous", data=event)

unleash_client = UnleashClient(
url=URL,
app_name=APP_NAME,
cache=cache,
disable_metrics=True,
disable_registration=True,
event_callback=example_callback,
)

unleash_client.initialize_client(fetch_toggles=False)
time.sleep(1)

assert trapped_events == 1


def test_context_handles_numerics():
cache = FileCache("MOCK_CACHE")
Expand Down
Loading