diff --git a/CHANGELOG.md b/CHANGELOG.md index 7884994a..82759e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ > went on. It needs to be consolidated into what will eventually become the > final changelog + documentation changes (if any). +* (Minor): The in-progress asynchronous client now exposes `feature_definitions()`. It returns the same dict as `UnleashClient`, keyed by feature name with each toggle's `type` and `project`. It is a plain method, not a coroutine, and it does not wait for the server: before the first fetch it answers from the cached state, and without any state it returns an empty dict. Every method on the client is now implemented. The client is still unexported. Nothing changes for code using `UnleashClient`. +* (Minor): The in-progress asynchronous client can now resolve variants. `get_variant()` returns the same variant dict as `UnleashClient` and emits the same impression events. It is a plain method, not a coroutine, and it does not wait for the server: before the first fetch it answers from the cached state, and a toggle the client does not know resolves to the disabled variant. When an initialized client is asked for a toggle it does not know, it logs at `verbose_log_level` that the client does not know the toggle. `feature_definitions()` still raises `NotImplementedError`. The client is still unexported. Nothing changes for code using `UnleashClient`. +* (Minor): The in-progress asynchronous client can now evaluate feature toggles. `is_enabled()` resolves a toggle against the feature state the client holds, with the same results, `fallback_function` handling and impression events as `UnleashClient`. It is a plain method, not a coroutine, and it does not wait for the server: before the first fetch it answers from the cached state, and a toggle the client does not know resolves to the fallback's answer, or to false without one. `get_variant()` and `feature_definitions()` still raise `NotImplementedError`. The client is still unexported. Nothing changes for code using `UnleashClient`. +* (Minor): The in-progress asynchronous client can now be initialized and shut down. `await initialize_client()` registers with the server, loads the cached feature state, polls every `refresh_interval` seconds and reports metrics every `metrics_interval` seconds, all on the running event loop. It returns without waiting for the server: the first fetch runs one refresh interval later, and until then the client holds the cached state. Like `UnleashClient`, it clears the cached ETag first, so the first fetch is unconditional. Polling is the only mode: `fetch_toggles` is accepted for parity but has no effect, `experimental_mode` is ignored, and there is no bootstrapping. `await destroy()` stops polling, sends the remaining metrics, closes the connection pool and is safe to call more than once. A `destroy()` that runs while registration is still in flight leaves nothing polling. The client also works as an async context manager (`async with AsyncUnleashClient(...) as client:`). `is_enabled()`, `get_variant()` and `feature_definitions()` still raise `NotImplementedError`. The client is still unexported. Nothing changes for code using `UnleashClient`. * (Minor): The in-progress asynchronous client gains an internal `_AsyncPollingConnector`, in the private `UnleashClient.connectors._async_connector` module. The module is not part of the public API and may change or disappear without notice. It fetches feature state over the asynchronous transport on its own scheduler. Starting it loads the cached state and returns without waiting for the server; the first fetch runs one refresh interval later. With a non-empty cache, READY is therefore emitted when the connector starts rather than after the first fetch. The asynchronous client does not use it yet. Nothing changes for code using `UnleashClient`. * (Minor): New `UnleashClient.errors` module with a hierarchy of SDK errors. `UnleashClientError` is the base of every error in the hierarchy, so catching it catches all of them without catching `Exception`. Each module gets one error grouping that module's errors, such as `InstanceRegistryError`, and specific errors derive from those, such as `MultipleInstancesNotAllowedError`. A client rejected under `InstanceAllowType.BLOCK` now raises `MultipleInstancesNotAllowedError` instead of a plain `Exception`, with the same message. It still derives from `Exception`, so existing `except Exception` handlers keep catching it. `TransportError` groups the errors raised while talking to the Unleash server. Other errors raised by the SDK are not part of the hierarchy yet. diff --git a/UnleashClient/clients/async_unleash_client.py b/UnleashClient/clients/async_unleash_client.py index be757081..47abfc86 100644 --- a/UnleashClient/clients/async_unleash_client.py +++ b/UnleashClient/clients/async_unleash_client.py @@ -3,6 +3,9 @@ ``pip install UnleashClient[async]``. """ +import asyncio +import warnings +from datetime import datetime, timezone from typing import Callable, Optional from yggdrasil_engine.engine import UnleashEngine @@ -16,24 +19,46 @@ from UnleashClient._headers import _HeaderFactory from UnleashClient._instance_registry import _get_instance_registry from UnleashClient._metrics import _AsyncMetricsReporter +from UnleashClient._payloads import _build_register_payload from UnleashClient.cache import BaseCache, FileCache +from UnleashClient.clients.unleash_client import _RunState from UnleashClient.config import ExperimentalMode, UnleashConfig -from UnleashClient.constants import REQUEST_RETRIES, REQUEST_TIMEOUT +from UnleashClient.connectors._async_connector import _AsyncPollingConnector +from UnleashClient.constants import ( + ETAG, + METRIC_LAST_SENT_TIME, + REQUEST_RETRIES, + REQUEST_TIMEOUT, +) from UnleashClient.events import BaseEvent from UnleashClient.impact_metrics import ImpactMetrics -from UnleashClient.utils import InstanceAllowType - -_NOT_IMPLEMENTED = ( - "AsyncUnleashClient is a work in progress and does not do anything yet. " - "Use UnleashClient." -) +from UnleashClient.utils import LOGGER, InstanceAllowType class AsyncUnleashClient: """ An asyncio-native client for the Unleash feature toggle system. - Not implemented yet: every method raises :class:`NotImplementedError`. + The client keeps feature state fresh by polling the Unleash server on the + event loop it was initialized on, and reports metrics on the same loop. + Streaming, offline mode and bootstrapping are not supported. + + Example:: + + async with AsyncUnleashClient( + url="https://unleash.example.com/api", + app_name="my-app", + custom_headers={"Authorization": ""}, + ) as client: + if client.is_enabled("new-checkout", {"userId": "42"}): + ... + + variant = client.get_variant("checkout-button", {"userId": "42"}) + + definitions = client.feature_definitions() + + client.impact_metrics.define_counter("purchases", "Number of purchases") + client.impact_metrics.increment_counter("purchases") """ def __init__( # noqa: PLR0913, PLR0917 @@ -115,14 +140,22 @@ def __init__( # noqa: PLR0913, PLR0917 events=self._event_dispatcher, ) self._transport: _AsyncTransport = _AsyncTransport(self._config, self._headers) - self._scheduler: _AsyncScheduler = _AsyncScheduler() + self._metrics_scheduler: _AsyncScheduler = _AsyncScheduler() self._metrics: _AsyncMetricsReporter = _AsyncMetricsReporter( config=self._config, transport=self._transport, - scheduler=self._scheduler, + scheduler=self._metrics_scheduler, engine=self._engine, impact_metrics=self.impact_metrics, ) + self._connector: Optional[_AsyncPollingConnector] = None + self._run_state: _RunState = _RunState.UNINITIALIZED + self._starting: bool = False + self._closed: bool = False + + @property + def is_initialized(self) -> bool: + return self._run_state == _RunState.INITIALIZED def is_enabled( self, @@ -144,7 +177,11 @@ def is_enabled( :param fallback_function: Allows users to provide a custom function to set default value. :return: Feature flag result """ - raise NotImplementedError(_NOT_IMPLEMENTED) + return self._evaluator.is_enabled( + feature_name=feature_name, + context=context, + fallback_function=fallback_function, + ) def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict: """ @@ -158,33 +195,167 @@ def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict :param context: Dictionary with context (e.g. IPs, email) for feature toggle. :return: Variant and feature flag status. """ - raise NotImplementedError(_NOT_IMPLEMENTED) + result = self._evaluator.get_variant(feature_name=feature_name, context=context) + + if not result.is_found and self.is_initialized: + LOGGER.log( + self._config.verbose_log_level, + "Attempted to get feature flag/variation %s, but the client does not know it.", + feature_name, + ) + + return result.variant def feature_definitions(self) -> dict: """ - Returns a dict containing all feature definitions known to the SDK at the time of calling. - Normally this would be a pared down version of the response from the Unleash API but this - may also be a result from bootstrapping or loading from backup. + Returns a dict containing all feature definitions known to the client at + the time of calling, keyed by feature name. This is a pared down version + of the response from the Unleash server, or of the cached state before + the client has fetched from the server. + + Notes: - Example response: + * It is a plain method, not a coroutine, and does not wait for the + server. Before the client has any feature state, it returns an empty + dict. - { - "feature1": { - "project": "default", - "type": "release", + Example response:: + + { + "feature1": { + "project": "default", + "type": "release", + } } - } + + :return: Feature definitions keyed by feature name. + """ + return self._evaluator.feature_definitions() + + async def initialize_client(self, fetch_toggles: bool = True) -> None: + """ + Initializes the client and starts communication with the Unleash server. + + This kicks off: + + * Client registration + * Loading the cached feature state + * Feature polling, every ``refresh_interval`` seconds + * Metrics reporting, every ``metrics_interval`` seconds + + Returns without waiting for the server's feature state. The first fetch + runs one ``refresh_interval`` after this returns, and until then the + client holds the cached state. + + Calling it again, or after :meth:`destroy`, warns and does nothing. + + This is done automatically when the client is used as an async context + manager: + + .. code-block:: python + + async with AsyncUnleashClient( + url="https://foo.bar", + app_name="myClient1", + instance_id="myinstanceid", + ) as client: + pass + + :param fetch_toggles: Accepted for parity with :class:`UnleashClient`. It + has no effect: the client always polls. + :raises aiohttp.InvalidURL: If registration is enabled and the URL is invalid. + :raises ValueError: If a custom strategy is invalid. """ - raise NotImplementedError(_NOT_IMPLEMENTED) + if self._closed or self._starting or self._run_state > _RunState.UNINITIALIZED: + warnings.warn( + "Attempted to initialize an Unleash Client instance that has already been initialized." + ) + return + + self._starting = True + try: + self._cache.mset( + {METRIC_LAST_SENT_TIME: datetime.now(timezone.utc), ETAG: ""} + ) + + if self._config.custom_strategies: + self._engine.register_custom_strategies(self._config.custom_strategies) + + if not self._config.disable_registration: + await self._transport.register( + _build_register_payload( + self._config, self._config.custom_strategies + ) + ) + + if self._closed: + return + + self._connector = _AsyncPollingConnector( + store=self._store, + transport=self._transport, + refresh_interval=self._config.refresh_interval, + refresh_jitter=self._config.refresh_jitter, + ) + await self._connector.start() + + if not self._config.disable_metrics: + self._metrics.start() + self._metrics_scheduler.start() - async def initialize_client(self) -> None: - raise NotImplementedError(_NOT_IMPLEMENTED) + self._run_state = _RunState.INITIALIZED + except Exception as excep: + LOGGER.warning( + "Exception during AsyncUnleashClient initialization: %s", excep + ) + raise + finally: + self._starting = False async def destroy(self) -> None: - raise NotImplementedError(_NOT_IMPLEMENTED) + """ + Gracefully shuts down the client: stops polling, sends the metrics + collected since the last send and closes the connection to the server. + + For cache teardown: + + * Default disk-backed FileCache instances are preserved on disk. + * Custom non-FileCache implementations will have ``destroy()`` called. + + Calling it more than once does nothing. + """ + if self._closed: + return + self._closed = True + self._run_state = _RunState.SHUTDOWN + + if self._connector is not None: + await self._connector.stop() + + await self._metrics.stop() + + try: + await self._metrics_scheduler.shutdown() + except Exception as exc: + LOGGER.warning("Exception during scheduler teardown: %s", exc) + + await self._transport.aclose() + + if not isinstance(self._cache, FileCache): + try: + self._cache.destroy() + except Exception as exc: + LOGGER.warning("Exception during cache teardown: %s", exc) + + if self._event_dispatcher is not None: + await asyncio.get_running_loop().run_in_executor( + None, self._event_dispatcher.close + ) async def __aenter__(self) -> "AsyncUnleashClient": - raise NotImplementedError(_NOT_IMPLEMENTED) + await self.initialize_client() + return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - raise NotImplementedError(_NOT_IMPLEMENTED) + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: + await self.destroy() + return False diff --git a/tests/specification_tests/test_async_client_specs.py b/tests/specification_tests/test_async_client_specs.py new file mode 100644 index 00000000..ab99da86 --- /dev/null +++ b/tests/specification_tests/test_async_client_specs.py @@ -0,0 +1,59 @@ +import platform +import sys +import uuid + +import pytest + +from tests.specification_tests.test_client_specs import TEST_DATA, TEST_NAMES +from tests.utilities.testing_constants import APP_NAME, URL +from UnleashClient.cache import FileCache +from UnleashClient.clients.async_unleash_client import AsyncUnleashClient + + +async def get_async_client(state, test_context=None, cache_directory=None): + cache_kwargs = {} + if cache_directory is not None: + cache_kwargs["directory"] = str(cache_directory) + + cache = FileCache("MOCK_CACHE", **cache_kwargs) + cache.bootstrap_from_dict(state) + env = "default" + if test_context is not None and "environment" in test_context: + env = test_context["environment"] + + unleash_client = AsyncUnleashClient( + url=URL, + app_name=APP_NAME, + instance_id="pytest_%s" % uuid.uuid4(), + disable_metrics=True, + disable_registration=True, + cache=cache, + environment=env, + ) + + await unleash_client.initialize_client(fetch_toggles=False) + return unleash_client + + +@pytest.mark.skipif( + sys.version_info < (3, 9) and platform.system() == "Windows", + reason="Requires Python >= 3.9 on Windows", +) +@pytest.mark.asyncio +@pytest.mark.parametrize("spec", TEST_DATA, ids=TEST_NAMES) +async def test_spec(spec, tmp_path): + state, test_data, is_variant_test = spec + context = test_data.get("context") + unleash_client = await get_async_client(state, context, tmp_path) + try: + if not is_variant_test: + toggle_name = test_data["toggleName"] + expected = test_data["expectedResult"] + assert unleash_client.is_enabled(toggle_name, context) == expected + else: + toggle_name = test_data["toggleName"] + expected = test_data["expectedResult"] + variant = unleash_client.get_variant(toggle_name, context) + assert variant == expected + finally: + await unleash_client.destroy() diff --git a/tests/unit_tests/clients/test_async_unleash_client.py b/tests/unit_tests/clients/test_async_unleash_client.py index 79ab4911..7b6cf190 100644 --- a/tests/unit_tests/clients/test_async_unleash_client.py +++ b/tests/unit_tests/clients/test_async_unleash_client.py @@ -1,16 +1,30 @@ +import asyncio import json from dataclasses import asdict +from typing import Callable import pytest - -from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE +import pytest_asyncio + +from tests.utilities.events import WAIT_TIMEOUT, EventRecorder +from tests.utilities.fake_unleash_server import FakeUnleash +from tests.utilities.mocks.mock_features import ( + MOCK_FEATURE_RESPONSE, + MOCK_FEATURE_RESPONSE_PROJECT, +) from tests.utilities.testing_constants import APP_NAME, URL from UnleashClient import INSTANCES, UnleashClient from UnleashClient._metrics import _AsyncMetricsReporter, _MetricsReporter from UnleashClient.cache import FileCache from UnleashClient.clients.async_unleash_client import AsyncUnleashClient -from UnleashClient.constants import FEATURES_URL +from UnleashClient.constants import ( + ETAG, + FEATURES_URL, + METRICS_URL, + REGISTER_URL, +) from UnleashClient.errors import MultipleInstancesNotAllowedError +from UnleashClient.events import UnleashEventType from UnleashClient.utils import InstanceAllowType @@ -202,17 +216,6 @@ def test_both_clients_load_the_same_state(tmpdir): sync_client.destroy() -def test_the_async_client_cannot_evaluate_yet(tmpdir): - client = build_async_client(tmpdir, url=URL, app_name=APP_NAME) - - with pytest.raises(NotImplementedError): - client.is_enabled("testFlag") - with pytest.raises(NotImplementedError): - client.get_variant("testFlag") - with pytest.raises(NotImplementedError): - client.feature_definitions() - - def test_async_client_builds_an_evaluator_over_its_engine_and_config(tmpdir): cache = FileCache(APP_NAME, directory=str(tmpdir)) cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) @@ -410,3 +413,347 @@ def test_the_async_client_silently_allows_duplicates_on_request(tmpdir, caplog): ) assert duplicate_warnings(caplog) == [] + + +API_PREFIX = "/api" +FEATURES_PATH = API_PREFIX + FEATURES_URL +REGISTER_PATH = API_PREFIX + REGISTER_URL +METRICS_PATH = API_PREFIX + METRICS_URL + + +@pytest_asyncio.fixture +async def server(): + fake = FakeUnleash() + await fake.start(API_PREFIX) + fake.on("POST", REGISTER_PATH, status=202, repeat=True) + fake.on("POST", METRICS_PATH, status=202, repeat=True) + try: + yield fake + finally: + await fake.close() + + +@pytest_asyncio.fixture +async def build_running_client(tmpdir, server: FakeUnleash): + built = [] + + def _build(**kwargs) -> AsyncUnleashClient: + kwargs.setdefault("url", server.base_url) + kwargs.setdefault("app_name", APP_NAME) + kwargs.setdefault("request_retries", 0) + kwargs.setdefault("disable_metrics", True) + client = build_async_client(tmpdir, **kwargs) + built.append(client) + return client + + try: + yield _build + finally: + for client in built: + await client.destroy() + + +async def until(predicate: Callable[[], bool]) -> None: + async def poll() -> None: + while not predicate(): + await asyncio.sleep(0.01) + + await asyncio.wait_for(poll(), WAIT_TIMEOUT) + + +@pytest.mark.asyncio +async def test_initializing_registers_with_the_server(server, build_running_client): + client = build_running_client(refresh_interval=3600) + + await client.initialize_client() + + (registration,) = server.calls("POST", REGISTER_PATH) + assert json.loads(registration.body)["appName"] == APP_NAME + assert client.is_initialized + + +@pytest.mark.asyncio +async def test_initializing_does_not_register_when_registration_is_disabled( + server, build_running_client +): + client = build_running_client(refresh_interval=3600, disable_registration=True) + + await client.initialize_client() + + assert server.calls("POST", REGISTER_PATH) == [] + + +@pytest.mark.asyncio +async def test_the_client_polls_features_from_the_server(server, build_running_client): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + client = build_running_client(refresh_interval=0.01) + + await client.initialize_client() + + await until(lambda: len(server.calls("GET", FEATURES_PATH)) >= 1) + + +@pytest.mark.asyncio +async def test_the_client_polls_even_when_asked_not_to_fetch_toggles( + server, build_running_client +): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + client = build_running_client(refresh_interval=0.01) + + await client.initialize_client(fetch_toggles=False) + + await until(lambda: len(server.calls("GET", FEATURES_PATH)) >= 1) + + +@pytest.mark.asyncio +async def test_the_first_poll_ignores_a_previously_cached_etag( + tmpdir, server, build_running_client +): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(ETAG, "W/stale") + client = build_running_client(refresh_interval=0.01, cache=cache) + + await client.initialize_client() + + await until(lambda: len(server.calls("GET", FEATURES_PATH)) >= 1) + assert "If-None-Match" not in server.calls("GET", FEATURES_PATH)[0].headers + + +@pytest.mark.asyncio +async def test_initializing_twice_warns_and_registers_once( + server, build_running_client +): + client = build_running_client(refresh_interval=3600) + await client.initialize_client() + + with pytest.warns(UserWarning, match="already been initialized"): + await client.initialize_client() + + assert len(server.calls("POST", REGISTER_PATH)) == 1 + + +@pytest.mark.asyncio +async def test_initializing_a_destroyed_client_warns_and_does_nothing( + server, build_running_client +): + client = build_running_client(refresh_interval=3600) + await client.destroy() + + with pytest.warns(UserWarning, match="already been initialized"): + await client.initialize_client() + + assert server.calls("POST", REGISTER_PATH) == [] + assert not client.is_initialized + + +@pytest.mark.asyncio +async def test_destroy_stops_polling(server, build_running_client): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + client = build_running_client(refresh_interval=0.01) + await client.initialize_client() + await until(lambda: len(server.calls("GET", FEATURES_PATH)) >= 1) + + await client.destroy() + polls = len(server.calls("GET", FEATURES_PATH)) + await asyncio.sleep(0.05) + + assert len(server.calls("GET", FEATURES_PATH)) == polls + assert not client.is_initialized + + +@pytest.mark.asyncio +async def test_destroy_sends_the_remaining_metrics(server, build_running_client): + client = build_running_client( + refresh_interval=3600, metrics_interval=3600, disable_metrics=False + ) + await client.initialize_client() + client.impact_metrics.define_counter("purchases", "Number of purchases") + client.impact_metrics.increment_counter("purchases", 3) + + await client.destroy() + + (metrics,) = server.calls("POST", METRICS_PATH) + (sent,) = json.loads(metrics.body)["impactMetrics"] + assert sent["name"] == "purchases" + + +@pytest.mark.asyncio +async def test_destroy_can_be_called_more_than_once(server, build_running_client): + client = build_running_client(refresh_interval=3600) + await client.initialize_client() + + await client.destroy() + await client.destroy() + + +@pytest.mark.asyncio +async def test_destroy_before_initializing_is_harmless(build_running_client): + client = build_running_client() + + await client.destroy() + + +@pytest.mark.asyncio +async def test_destroy_during_registration_leaves_nothing_polling( + server, build_running_client +): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + client = build_running_client(refresh_interval=0.01) + client._transport.register = lambda payload: asyncio.sleep(0.05) + + initializing = asyncio.ensure_future(client.initialize_client()) + await asyncio.sleep(0) + await client.destroy() + await initializing + await asyncio.sleep(0.05) + + assert server.calls("GET", FEATURES_PATH) == [] + assert not client.is_initialized + + +@pytest.mark.asyncio +async def test_the_context_manager_initializes_and_destroys( + server, build_running_client +): + server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) + client = build_running_client(refresh_interval=0.01) + + async with client as entered: + assert entered is client + await until(lambda: len(server.calls("GET", FEATURES_PATH)) >= 1) + + polls = len(server.calls("GET", FEATURES_PATH)) + await asyncio.sleep(0.05) + assert len(server.calls("GET", FEATURES_PATH)) == polls + assert len(server.calls("POST", REGISTER_PATH)) == 1 + + +@pytest.mark.asyncio +async def test_is_enabled_resolves_toggles_from_the_cached_state( + tmpdir, build_running_client +): + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) + client = build_running_client(refresh_interval=3600, cache=cache) + + await client.initialize_client() + + assert client.is_enabled("testFlag") is True + assert client.is_enabled("testConstraintFlag") is False + + +@pytest.mark.asyncio +async def test_is_enabled_is_false_for_an_unknown_toggle(build_running_client): + client = build_running_client(refresh_interval=3600) + + await client.initialize_client() + + assert client.is_enabled("notAFlag") is False + + +@pytest.mark.asyncio +async def test_is_enabled_answers_unknown_toggles_with_the_fallback( + build_running_client, +): + client = build_running_client(refresh_interval=3600) + + await client.initialize_client() + + assert ( + client.is_enabled( + "notAFlag", + {"userId": "42"}, + fallback_function=lambda feature_name, context: context["userId"] == "42", + ) + is True + ) + + +@pytest.mark.asyncio +async def test_is_enabled_emits_impression_events(tmpdir, build_running_client): + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) + recorder = EventRecorder() + client = build_running_client( + refresh_interval=3600, cache=cache, event_callback=recorder + ) + + await client.initialize_client() + client.is_enabled("testFlag", {"userId": "42"}) + + (event,) = recorder.wait_for(UnleashEventType.FEATURE_FLAG) + assert event.feature_name == "testFlag" + assert event.enabled is True + assert event.context["userId"] == "42" + + +@pytest.mark.asyncio +async def test_get_variant_resolves_variants_from_the_cached_state( + tmpdir, build_running_client +): + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) + client = build_running_client(refresh_interval=3600, cache=cache) + + await client.initialize_client() + + variant = client.get_variant("testVariations", {"userId": "2"}) + assert variant["name"] == "VarA" + assert variant["enabled"] + assert variant["feature_enabled"] + + +@pytest.mark.asyncio +async def test_get_variant_is_disabled_for_an_unknown_toggle(build_running_client): + client = build_running_client(refresh_interval=3600) + + await client.initialize_client() + + variant = client.get_variant("notAFlag") + assert variant["name"] == "disabled" + assert not variant["enabled"] + assert not variant["feature_enabled"] + + +@pytest.mark.asyncio +async def test_get_variant_emits_impression_events(tmpdir, build_running_client): + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) + recorder = EventRecorder() + client = build_running_client( + refresh_interval=3600, cache=cache, event_callback=recorder + ) + + await client.initialize_client() + client.get_variant("testVariations", {"userId": "2"}) + + (event,) = recorder.wait_for(UnleashEventType.VARIANT) + assert event.feature_name == "testVariations" + assert event.enabled is True + assert event.variant == "VarA" + assert event.context["userId"] == "2" + + +@pytest.mark.asyncio +async def test_feature_definitions_reports_the_toggles_in_the_cached_state( + tmpdir, build_running_client +): + cache = FileCache(APP_NAME, directory=str(tmpdir)) + cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE_PROJECT)) + client = build_running_client(refresh_interval=3600, cache=cache) + + await client.initialize_client() + + assert client.feature_definitions() == { + "ivan-project": {"type": "release", "project": "default"} + } + + +@pytest.mark.asyncio +async def test_feature_definitions_is_empty_without_any_state(build_running_client): + client = build_running_client(refresh_interval=3600) + + await client.initialize_client() + + assert client.feature_definitions() == {}