diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de47af1..31a07093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ * (Minor): Event callbacks are now invoked on a dedicated background thread instead of on whichever thread produced the event. `is_enabled()` and `get_variant()` no longer wait for your callback, so a slow callback can't hold up flag evaluation. Three consequences worth knowing about: callbacks can no longer read thread local state from the caller (Flask `g`, the current Django request, contextvars); they return before the callback has run, so tests asserting straight after the call now need to wait; and reassigning `unleash_event_callback` after construction is no longer honoured. * (Minor): Connectors take an `EventDispatcher` instead of `ready_callback`/`event_callback`. These classes aren't part of the documented API, so this only affects code importing from `UnleashClient.connectors` directly. * (Minor): Request headers are now assembled once, by an internal `HeaderFactory`, and passed to each collaborator complete. The headers on the wire are unchanged. `PollingConnector` and `StreamingConnector` no longer add `unleash-interval` and `Accept`/`Content-Type`/`Unleash-Client-Spec` themselves, so code importing from `UnleashClient.connectors` directly must now supply complete headers. Nothing changes for code using `UnleashClient`. -* (Minor): Applying feature state (the cache write, the handover to the engine, and the READY and FETCHED events that follow) now happens in one internal `FeatureStore` instead of being re-implemented by each connector. The cache writes, engine updates and events are exactly what they were. Connectors now take a `store` instead of `engine`, `cache` and `events`, and `BaseConnector.load_features()` is gone, so code importing from `UnleashClient.connectors` directly must build a `FeatureStore` and call `store.load_from_cache()`. Nothing changes for code using `UnleashClient`. +* (Minor): Applying feature state (the cache write, the handover to the engine, and the READY and FETCHED events that follow) now happens in one internal `_FeatureStore`, in the private `UnleashClient._feature_store` module, instead of being re-implemented by each connector. The module is not part of the public API and may change or disappear without notice. The cache writes, engine updates and events are exactly what they were. Connectors now take a `store` instead of `engine`, `cache` and `events`, and `BaseConnector.load_features()` is gone, so code importing from `UnleashClient.connectors` directly must build a `_FeatureStore` and call `store.load_from_cache()`. Nothing changes for code using `UnleashClient`. * (Minor): The `engine` and `cache` attributes are gone from `UnleashClient`. Both were always configured through the constructor (pass `cache=` to supply your own), and neither appears in the documented API. * (Bugfix): `refresh_jitter` now reaches the polling job. It was accepted by the constructor, documented, and applied to the offline refresh job, but never passed to the polling connector, so jitter was silently dropped in the default polling mode. * (Minor): Constructor arguments are now normalized once into an internal `UnleashConfig` object rather than being copied onto the client attribute by attribute. The public `unleash_*` attributes keep their exact values and stay writable, reading and writing through that object, so nothing in calling code needs to change. This is groundwork for an asynchronous client that shares the same configuration handling. diff --git a/UnleashClient/store.py b/UnleashClient/_feature_store.py similarity index 94% rename from UnleashClient/store.py rename to UnleashClient/_feature_store.py index 61246371..5fdfd169 100644 --- a/UnleashClient/store.py +++ b/UnleashClient/_feature_store.py @@ -17,7 +17,7 @@ from UnleashClient.utils import LOGGER -class FeatureStore: +class _FeatureStore: """ Owns what happens to feature state once it has arrived: the cache write, the handover to the engine, and the events that follow. @@ -25,6 +25,14 @@ class FeatureStore: There is one method per source rather than a single ``apply``, because the three steps happen in a different order, over different payloads, with different failure handling depending on where the state came from. + + Example:: + + store = _FeatureStore(engine=UnleashEngine(), cache=cache, events=dispatcher) + store.load_from_cache() + + result = transport.fetch_features(etag=store.cached_etag) + store.apply_fetched(raw_state=result.raw_state, etag=result.etag) """ def __init__( diff --git a/UnleashClient/clients/async_unleash_client.py b/UnleashClient/clients/async_unleash_client.py index 0397a027..bd6a81b8 100644 --- a/UnleashClient/clients/async_unleash_client.py +++ b/UnleashClient/clients/async_unleash_client.py @@ -10,6 +10,7 @@ from UnleashClient._async_scheduler import _AsyncScheduler from UnleashClient._async_transport import _AsyncTransport from UnleashClient._evaluator import _Evaluator +from UnleashClient._feature_store import _FeatureStore from UnleashClient._instance_registry import _get_instance_registry from UnleashClient.async_metrics_reporter import AsyncMetricsReporter from UnleashClient.cache import BaseCache, FileCache @@ -19,7 +20,6 @@ from UnleashClient.events import BaseEvent, EventDispatcher from UnleashClient.headers import HeaderFactory from UnleashClient.impact_metrics import ImpactMetrics -from UnleashClient.store import FeatureStore from UnleashClient.utils import InstanceAllowType _NOT_IMPLEMENTED = ( @@ -104,7 +104,7 @@ def __init__( # noqa: PLR0913, PLR0917 self._cache: BaseCache = cache or FileCache( self._config.app_name, directory=cache_directory ) - self._store: FeatureStore = FeatureStore( + self._store: _FeatureStore = _FeatureStore( engine=self._engine, cache=self._cache, events=self._event_dispatcher ) self._evaluator: _Evaluator = _Evaluator( diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index 720fdf44..1ffa32ed 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -10,6 +10,7 @@ from yggdrasil_engine.engine import UnleashEngine from UnleashClient._evaluator import _Evaluator +from UnleashClient._feature_store import _FeatureStore from UnleashClient._instance_registry import _get_instance_registry from UnleashClient._scheduler import _ScheduledJob, _Scheduler from UnleashClient._transport import _Transport @@ -43,7 +44,6 @@ from UnleashClient.impact_metrics import ImpactMetrics from UnleashClient.metrics_reporter import MetricsReporter from UnleashClient.payloads import build_register_payload -from UnleashClient.store import FeatureStore from UnleashClient.utils import ( LOGGER, InstanceAllowType, @@ -207,7 +207,7 @@ def __init__( # noqa: PLR0913, PLR0917 self._cache.mset({METRIC_LAST_SENT_TIME: datetime.now(timezone.utc), ETAG: ""}) self.unleash_bootstrapped = self._cache.bootstrapped - self._store = FeatureStore( + self._store = _FeatureStore( engine=self._engine, cache=self._cache, events=self.__events ) @@ -240,7 +240,7 @@ def __init__( # noqa: PLR0913, PLR0917 # move it earlier for bootstrapped clients. See the TODO on # BootstrapConnector. BootstrapConnector( - store=FeatureStore(engine=self._engine, cache=self._cache) + store=_FeatureStore(engine=self._engine, cache=self._cache) ).start() self.connector: BaseConnector = None diff --git a/UnleashClient/connectors/_async_connector.py b/UnleashClient/connectors/_async_connector.py index 8d8bf66b..35ab04a2 100644 --- a/UnleashClient/connectors/_async_connector.py +++ b/UnleashClient/connectors/_async_connector.py @@ -3,11 +3,11 @@ from UnleashClient._async_scheduler import _AsyncScheduler from UnleashClient._async_transport import _AsyncTransport -from UnleashClient.store import FeatureStore +from UnleashClient._feature_store import _FeatureStore class _AsyncBaseConnector(ABC): - def __init__(self, store: FeatureStore) -> None: + def __init__(self, store: _FeatureStore) -> None: """ :param store: Applies feature state to the engine and the cache, and emits the events that follow. @@ -42,7 +42,7 @@ class _AsyncPollingConnector(_AsyncBaseConnector): def __init__( self, - store: FeatureStore, + store: _FeatureStore, transport: _AsyncTransport, refresh_interval: float = 15, refresh_jitter: Optional[float] = None, diff --git a/UnleashClient/connectors/base_connector.py b/UnleashClient/connectors/base_connector.py index f7404137..d546368a 100644 --- a/UnleashClient/connectors/base_connector.py +++ b/UnleashClient/connectors/base_connector.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod -from UnleashClient.store import FeatureStore +from UnleashClient._feature_store import _FeatureStore class BaseConnector(ABC): - def __init__(self, store: FeatureStore): + def __init__(self, store: _FeatureStore): """ :param store: Applies feature state to the engine and the cache, and emits the events that follow. diff --git a/UnleashClient/connectors/bootstrap_connector.py b/UnleashClient/connectors/bootstrap_connector.py index bad1a2d5..191600b6 100644 --- a/UnleashClient/connectors/bootstrap_connector.py +++ b/UnleashClient/connectors/bootstrap_connector.py @@ -1,4 +1,4 @@ -from UnleashClient.store import FeatureStore +from UnleashClient._feature_store import _FeatureStore from .base_connector import BaseConnector @@ -6,7 +6,7 @@ class BootstrapConnector(BaseConnector): def __init__( self, - store: FeatureStore, + store: _FeatureStore, ): super().__init__(store) self.job = None diff --git a/UnleashClient/connectors/offline_connector.py b/UnleashClient/connectors/offline_connector.py index 82306e83..2db5c46e 100644 --- a/UnleashClient/connectors/offline_connector.py +++ b/UnleashClient/connectors/offline_connector.py @@ -1,7 +1,7 @@ from typing import Optional +from UnleashClient._feature_store import _FeatureStore from UnleashClient._scheduler import _ScheduledJob, _Scheduler -from UnleashClient.store import FeatureStore from .base_connector import BaseConnector @@ -9,7 +9,7 @@ class OfflineConnector(BaseConnector): def __init__( self, - store: FeatureStore, + store: _FeatureStore, scheduler: _Scheduler, refresh_interval: int = 15, refresh_jitter: Optional[int] = None, diff --git a/UnleashClient/connectors/polling_connector.py b/UnleashClient/connectors/polling_connector.py index 48145981..aada39bb 100644 --- a/UnleashClient/connectors/polling_connector.py +++ b/UnleashClient/connectors/polling_connector.py @@ -1,8 +1,8 @@ from typing import Optional +from UnleashClient._feature_store import _FeatureStore from UnleashClient._scheduler import _ScheduledJob, _Scheduler from UnleashClient._transport import _Transport -from UnleashClient.store import FeatureStore from .base_connector import BaseConnector @@ -12,7 +12,7 @@ class PollingConnector(BaseConnector): def __init__( self, - store: FeatureStore, + store: _FeatureStore, scheduler: _Scheduler, transport: _Transport, refresh_interval: int = 15, diff --git a/UnleashClient/connectors/streaming_connector.py b/UnleashClient/connectors/streaming_connector.py index 1a95ca07..e8500a36 100644 --- a/UnleashClient/connectors/streaming_connector.py +++ b/UnleashClient/connectors/streaming_connector.py @@ -4,16 +4,16 @@ from ld_eventsource import SSEClient from ld_eventsource.config import ConnectStrategy, ErrorStrategy, RetryDelayStrategy +from UnleashClient._feature_store import _FeatureStore from UnleashClient.connectors.base_connector import BaseConnector from UnleashClient.constants import STREAMING_URL -from UnleashClient.store import FeatureStore from UnleashClient.utils import LOGGER class StreamingConnector(BaseConnector): def __init__( self, - store: FeatureStore, + store: _FeatureStore, url: str, headers: dict, request_timeout: int, diff --git a/tests/unit_tests/connectors/test_async_connector.py b/tests/unit_tests/connectors/test_async_connector.py index f250f0f2..dc5229d0 100644 --- a/tests/unit_tests/connectors/test_async_connector.py +++ b/tests/unit_tests/connectors/test_async_connector.py @@ -11,12 +11,12 @@ from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE from tests.utilities.testing_constants import APP_NAME, ETAG_VALUE from UnleashClient._async_transport import _AsyncTransport +from UnleashClient._feature_store import _FeatureStore from UnleashClient.config import UnleashConfig from UnleashClient.connectors._async_connector import _AsyncPollingConnector from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType from UnleashClient.headers import HeaderFactory -from UnleashClient.store import FeatureStore API_PREFIX = "/api" FEATURES_PATH = API_PREFIX + FEATURES_URL @@ -40,7 +40,7 @@ async def build_connector(server: FakeUnleash): built = [] def _build_connector( - store: FeatureStore, refresh_interval: float = INTERVAL + store: _FeatureStore, refresh_interval: float = INTERVAL ) -> _AsyncPollingConnector: config = UnleashConfig(server.base_url, APP_NAME, request_retries=0) transport = _AsyncTransport(config, HeaderFactory(config)) @@ -77,7 +77,7 @@ async def test_start_makes_cached_state_evaluable_before_any_fetch( cache_empty.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) engine = UnleashEngine() connector = build_connector( - store=FeatureStore(engine=engine, cache=cache_empty), refresh_interval=NEVER + store=_FeatureStore(engine=engine, cache=cache_empty), refresh_interval=NEVER ) await connector.start() @@ -98,7 +98,7 @@ async def test_polling_applies_fetched_state_and_caches_its_etag( repeat=True, ) engine = UnleashEngine() - connector = build_connector(store=FeatureStore(engine=engine, cache=cache_empty)) + connector = build_connector(store=_FeatureStore(engine=engine, cache=cache_empty)) await connector.start() @@ -116,7 +116,7 @@ async def test_polling_sends_the_cached_etag(server, build_connector, cache_empt ) server.on("GET", FEATURES_PATH, status=304, repeat=True) connector = build_connector( - store=FeatureStore(engine=UnleashEngine(), cache=cache_empty) + store=_FeatureStore(engine=UnleashEngine(), cache=cache_empty) ) await connector.start() @@ -134,7 +134,7 @@ async def test_failed_poll_keeps_the_last_applied_state( server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE) server.on("GET", FEATURES_PATH, status=500, repeat=True) engine = UnleashEngine() - connector = build_connector(store=FeatureStore(engine=engine, cache=cache_empty)) + connector = build_connector(store=_FeatureStore(engine=engine, cache=cache_empty)) await connector.start() @@ -152,7 +152,9 @@ async def test_polling_emits_fetched_on_every_fetch_and_ready_once( ): server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, repeat=True) connector = build_connector( - store=FeatureStore(engine=UnleashEngine(), cache=cache_empty, events=dispatcher) + store=_FeatureStore( + engine=UnleashEngine(), cache=cache_empty, events=dispatcher + ) ) await connector.start() @@ -167,7 +169,7 @@ async def test_polling_emits_fetched_on_every_fetch_and_ready_once( async def test_stop_interrupts_a_fetch_in_flight(server, build_connector, cache_empty): server.on("GET", FEATURES_PATH, payload=MOCK_FEATURE_RESPONSE, hang=True) engine = UnleashEngine() - connector = build_connector(store=FeatureStore(engine=engine, cache=cache_empty)) + connector = build_connector(store=_FeatureStore(engine=engine, cache=cache_empty)) await connector.start() await until(lambda: len(server.calls("GET", FEATURES_PATH)) == 1) @@ -181,7 +183,7 @@ async def test_stop_interrupts_a_fetch_in_flight(server, build_connector, cache_ @mark.asyncio async def test_stop_is_safe_when_never_started(build_connector, cache_empty): connector = build_connector( - store=FeatureStore(engine=UnleashEngine(), cache=cache_empty) + store=_FeatureStore(engine=UnleashEngine(), cache=cache_empty) ) await connector.stop() diff --git a/tests/unit_tests/connectors/test_offline_connector.py b/tests/unit_tests/connectors/test_offline_connector.py index 02f2fac8..f5bd2d58 100644 --- a/tests/unit_tests/connectors/test_offline_connector.py +++ b/tests/unit_tests/connectors/test_offline_connector.py @@ -4,11 +4,11 @@ from tests.utilities.events import WAIT_TIMEOUT, EventRecorder from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE +from UnleashClient._feature_store import _FeatureStore from UnleashClient._scheduler import _Scheduler from UnleashClient.connectors import OfflineConnector from UnleashClient.constants import FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType -from UnleashClient.store import FeatureStore def test_offline_connector_loads_features_on_start(cache_empty): @@ -19,7 +19,7 @@ def test_offline_connector_loads_features_on_start(cache_empty): temp_cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) connector = OfflineConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, ) @@ -36,7 +36,7 @@ def test_offline_connector_start_stop(cache_empty): temp_cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) connector = OfflineConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, refresh_interval=1, ) @@ -59,7 +59,7 @@ def test_offline_connector_emits_ready_event( temp_cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) connector = OfflineConnector( - store=FeatureStore(engine=engine, cache=temp_cache, events=dispatcher), + store=_FeatureStore(engine=engine, cache=temp_cache, events=dispatcher), scheduler=scheduler, ) @@ -78,7 +78,7 @@ def test_offline_connector_emits_ready_on_an_empty_cache( scheduler = _Scheduler() connector = OfflineConnector( - store=FeatureStore( + store=_FeatureStore( engine=UnleashEngine(), cache=cache_empty, events=dispatcher ), scheduler=scheduler, @@ -100,7 +100,7 @@ def test_offline_connector_without_a_dispatcher_does_not_emit(cache_empty): temp_cache.set(FEATURES_URL, json.dumps(MOCK_FEATURE_RESPONSE)) connector = OfflineConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, ) diff --git a/tests/unit_tests/connectors/test_polling_connector.py b/tests/unit_tests/connectors/test_polling_connector.py index c6b4c5e0..9b79498d 100644 --- a/tests/unit_tests/connectors/test_polling_connector.py +++ b/tests/unit_tests/connectors/test_polling_connector.py @@ -18,6 +18,7 @@ REQUEST_TIMEOUT, URL, ) +from UnleashClient._feature_store import _FeatureStore from UnleashClient._scheduler import _Scheduler from UnleashClient._transport import _Transport from UnleashClient.config import UnleashConfig @@ -25,7 +26,6 @@ from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType from UnleashClient.headers import HeaderFactory -from UnleashClient.store import FeatureStore FULL_FEATURE_URL = URL + FEATURES_URL @@ -57,7 +57,7 @@ def test_polling_connector_fetch_and_load(cache_empty): temp_cache = cache_empty connector = PollingConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, transport=build_transport(), ) @@ -78,7 +78,7 @@ def test_polling_connector_fetch_and_load_project(cache_empty): temp_cache = cache_empty connector = PollingConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, transport=build_transport(project_name=PROJECT_NAME), ) @@ -98,7 +98,7 @@ def test_polling_connector_fetch_and_load_failure(cache_empty): temp_cache = cache_empty connector = PollingConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, transport=build_transport(), ) @@ -128,7 +128,7 @@ def test_polling_connector_emits_fetched_and_ready( ) connector = PollingConnector( - store=FeatureStore(engine=engine, cache=cache_empty, events=dispatcher), + store=_FeatureStore(engine=engine, cache=cache_empty, events=dispatcher), scheduler=scheduler, transport=build_transport(), # Huge refresh interval to avoid any polling during the test. That @@ -155,7 +155,7 @@ def test_polling_connector_emits_ready_once_across_polls( ) connector = PollingConnector( - store=FeatureStore(engine=engine, cache=cache_empty, events=dispatcher), + store=_FeatureStore(engine=engine, cache=cache_empty, events=dispatcher), scheduler=scheduler, transport=build_transport(), ) @@ -185,7 +185,7 @@ def test_polling_connector_start_stop(cache_empty): temp_cache = cache_empty connector = PollingConnector( - store=FeatureStore(engine=engine, cache=temp_cache), + store=_FeatureStore(engine=engine, cache=temp_cache), scheduler=scheduler, transport=build_transport(), refresh_interval=1, diff --git a/tests/unit_tests/test_store.py b/tests/unit_tests/test_feature_store.py similarity index 83% rename from tests/unit_tests/test_store.py rename to tests/unit_tests/test_feature_store.py index c8d0d730..b815fc91 100644 --- a/tests/unit_tests/test_store.py +++ b/tests/unit_tests/test_feature_store.py @@ -11,10 +11,10 @@ MOCK_FEATURE_RESPONSE_PROJECT, ) from tests.utilities.testing_constants import ETAG_VALUE +from UnleashClient._feature_store import _FeatureStore from UnleashClient.cache import BaseCache from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType -from UnleashClient.store import FeatureStore FEATURES = json.dumps(MOCK_FEATURE_RESPONSE) OTHER_FEATURES = json.dumps(MOCK_FEATURE_RESPONSE_PROJECT) @@ -60,7 +60,7 @@ def test_load_from_cache_applies_the_cached_state(cache_empty): engine = UnleashEngine() cache_empty.set(FEATURES_URL, FEATURES) - FeatureStore(engine, cache_empty).load_from_cache() + _FeatureStore(engine, cache_empty).load_from_cache() assert engine.is_enabled("testFlag", {}).is_enabled @@ -68,7 +68,7 @@ def test_load_from_cache_applies_the_cached_state(cache_empty): def test_load_from_cache_emits_ready(cache_empty, dispatcher, recorder): cache_empty.set(FEATURES_URL, FEATURES) - FeatureStore(UnleashEngine(), cache_empty, dispatcher).load_from_cache() + _FeatureStore(UnleashEngine(), cache_empty, dispatcher).load_from_cache() assert recorder.wait_for(UnleashEventType.READY, count=1) dispatcher.close(timeout=WAIT_TIMEOUT) @@ -81,7 +81,7 @@ def test_load_from_cache_on_an_empty_cache_neither_raises_nor_emits( ): engine = UnleashEngine() - FeatureStore(engine, cache_empty, dispatcher).load_from_cache() + _FeatureStore(engine, cache_empty, dispatcher).load_from_cache() dispatcher.close(timeout=WAIT_TIMEOUT) assert engine.list_known_toggles() == [] @@ -94,7 +94,7 @@ def test_load_from_cache_swallows_an_unparseable_body( engine = UnleashEngine() cache_empty.set(FEATURES_URL, "not json") - FeatureStore(engine, cache_empty, dispatcher).load_from_cache() + _FeatureStore(engine, cache_empty, dispatcher).load_from_cache() dispatcher.close(timeout=WAIT_TIMEOUT) assert engine.list_known_toggles() == [] @@ -105,7 +105,7 @@ def test_load_from_cache_without_a_dispatcher_still_applies(cache_empty): engine = UnleashEngine() cache_empty.set(FEATURES_URL, FEATURES) - FeatureStore(engine, cache_empty).load_from_cache() + _FeatureStore(engine, cache_empty).load_from_cache() assert engine.is_enabled("testFlag", {}).is_enabled @@ -113,7 +113,7 @@ def test_load_from_cache_without_a_dispatcher_still_applies(cache_empty): def test_apply_fetched_caches_the_payload_and_the_etag(cache_empty): engine = UnleashEngine() - FeatureStore(engine, cache_empty).apply_fetched(FEATURES, ETAG_VALUE) + _FeatureStore(engine, cache_empty).apply_fetched(FEATURES, ETAG_VALUE) assert cache_empty.get(FEATURES_URL) == FEATURES assert cache_empty.get(ETAG) == ETAG_VALUE @@ -125,7 +125,7 @@ def test_apply_fetched_without_state_keeps_the_cached_provisioning(cache_empty): cache_empty.set(FEATURES_URL, FEATURES) # What a 304 looks like coming out of the API layer. - FeatureStore(engine, cache_empty).apply_fetched(None, ETAG_VALUE) + _FeatureStore(engine, cache_empty).apply_fetched(None, ETAG_VALUE) assert cache_empty.get(FEATURES_URL) == FEATURES assert engine.is_enabled("testFlag", {}).is_enabled @@ -136,7 +136,7 @@ def test_apply_fetched_without_state_emits_ready_but_not_fetched( ): cache_empty.set(FEATURES_URL, FEATURES) - FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(None) + _FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(None) assert recorder.wait_for(UnleashEventType.READY, count=1) dispatcher.close(timeout=WAIT_TIMEOUT) @@ -148,13 +148,13 @@ def test_apply_fetched_without_state_emits_ready_but_not_fetched( def test_apply_fetched_with_a_falsy_etag_keeps_the_cached_one(cache_empty): cache_empty.set(ETAG, ETAG_VALUE) - FeatureStore(UnleashEngine(), cache_empty).apply_fetched(FEATURES, "") + _FeatureStore(UnleashEngine(), cache_empty).apply_fetched(FEATURES, "") assert cache_empty.get(ETAG) == ETAG_VALUE def test_apply_fetched_emits_ready_before_fetched(cache_empty, dispatcher, recorder): - FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(FEATURES) + _FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(FEATURES) assert recorder.wait_for(UnleashEventType.READY, count=1) assert recorder.wait_for(UnleashEventType.FETCHED, count=1) @@ -169,7 +169,7 @@ def test_apply_fetched_emits_ready_before_fetched(cache_empty, dispatcher, recor def test_apply_fetched_carries_the_raw_response_on_the_event( cache_empty, dispatcher, recorder ): - FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(FEATURES) + _FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_fetched(FEATURES) assert recorder.wait_for(UnleashEventType.FETCHED, count=1) dispatcher.close(timeout=WAIT_TIMEOUT) @@ -185,7 +185,7 @@ def test_apply_fetched_with_an_unparseable_payload_still_emits_fetched_then_read ): engine = UnleashEngine() - FeatureStore(engine, cache_empty, dispatcher).apply_fetched("not json") + _FeatureStore(engine, cache_empty, dispatcher).apply_fetched("not json") assert recorder.wait_for(UnleashEventType.FETCHED, count=1) assert recorder.wait_for(UnleashEventType.READY, count=1) @@ -201,7 +201,7 @@ def test_apply_fetched_with_an_unparseable_payload_still_emits_fetched_then_read def test_apply_fetched_emits_ready_once_across_polls(cache_empty, dispatcher, recorder): - store = FeatureStore(UnleashEngine(), cache_empty, dispatcher) + store = _FeatureStore(UnleashEngine(), cache_empty, dispatcher) store.apply_fetched(FEATURES) store.apply_fetched(FEATURES) @@ -218,7 +218,7 @@ def test_apply_fetched_feeds_the_engine_from_the_cache(): engine = UnleashEngine() cache = RoundTrippingCache(on_read=OTHER_FEATURES) - FeatureStore(engine, cache).apply_fetched(FEATURES) + _FeatureStore(engine, cache).apply_fetched(FEATURES) assert cache.written[FEATURES_URL] == FEATURES assert engine.is_enabled("ivan-project", {}).is_enabled @@ -230,7 +230,7 @@ def test_apply_streamed_applies_the_payload_and_caches_the_engine_state( ): engine = UnleashEngine() - FeatureStore(engine, cache_empty, dispatcher).apply_streamed( + _FeatureStore(engine, cache_empty, dispatcher).apply_streamed( FEATURES, emit_ready=True ) @@ -244,7 +244,7 @@ def test_apply_streamed_applies_the_payload_and_caches_the_engine_state( def test_apply_streamed_caches_the_merged_state_not_the_delta(cache_empty): - store = FeatureStore(UnleashEngine(), cache_empty) + store = _FeatureStore(UnleashEngine(), cache_empty) store.apply_streamed(FEATURES, emit_ready=True) store.apply_streamed(DELTA) @@ -253,21 +253,21 @@ def test_apply_streamed_caches_the_merged_state_not_the_delta(cache_empty): # A cold engine reading that cache back sees the flags from both payloads. recovered = UnleashEngine() - FeatureStore(recovered, cache_empty).load_from_cache() + _FeatureStore(recovered, cache_empty).load_from_cache() assert recovered.is_enabled("deltaFlag", {}).is_enabled assert recovered.is_enabled("testFlag", {}).is_enabled def test_apply_streamed_does_not_emit_unless_asked(cache_empty, dispatcher, recorder): - FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_streamed(FEATURES) + _FeatureStore(UnleashEngine(), cache_empty, dispatcher).apply_streamed(FEATURES) dispatcher.close(timeout=WAIT_TIMEOUT) assert recorder.events == [] def test_apply_streamed_raises_on_a_bad_payload(cache_empty): - store = FeatureStore(UnleashEngine(), cache_empty) + store = _FeatureStore(UnleashEngine(), cache_empty) with pytest.raises(Exception): store.apply_streamed("not json") @@ -278,18 +278,18 @@ def test_apply_streamed_raises_on_a_bad_payload(cache_empty): def test_cached_etag_returns_the_cached_value(cache_empty): cache_empty.set(ETAG, ETAG_VALUE) - assert FeatureStore(UnleashEngine(), cache_empty).cached_etag == ETAG_VALUE + assert _FeatureStore(UnleashEngine(), cache_empty).cached_etag == ETAG_VALUE def test_cached_etag_is_empty_when_the_cache_has_no_etag(): cache = RoundTrippingCache(on_read=FEATURES) - assert FeatureStore(UnleashEngine(), cache).cached_etag == "" + assert _FeatureStore(UnleashEngine(), cache).cached_etag == "" def test_a_failing_dispatcher_does_not_break_the_state_path(cache_empty): engine = UnleashEngine() - FeatureStore(engine, cache_empty, FailingDispatcher()).apply_fetched(FEATURES) + _FeatureStore(engine, cache_empty, FailingDispatcher()).apply_fetched(FEATURES) assert engine.is_enabled("testFlag", {}).is_enabled