From 07b51ab44679a90c3543fc8dfc485fd9495fc9a8 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Sat, 1 Nov 2025 10:21:35 +0200 Subject: [PATCH 1/5] chore: break out core evaluation logic from the client --- UnleashClient/__init__.py | 227 ++++-------------------------- UnleashClient/core/__init__.py | 3 + UnleashClient/core/client.py | 235 ++++++++++++++++++++++++++++++++ UnleashClient/core/contracts.py | 27 ++++ tests/unit_tests/test_client.py | 8 +- 5 files changed, 296 insertions(+), 204 deletions(-) create mode 100644 UnleashClient/core/__init__.py create mode 100644 UnleashClient/core/client.py create mode 100644 UnleashClient/core/contracts.py diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index 096b7d2f..7f8e2b8f 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -4,10 +4,8 @@ import threading import uuid import warnings -from dataclasses import asdict from datetime import datetime, timezone -from enum import IntEnum -from typing import Any, Callable, Dict, Optional +from typing import Callable, Optional from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.job import Job @@ -27,7 +25,6 @@ ) from UnleashClient.constants import ( APPLICATION_HEADERS, - DISABLED_VARIATION, ETAG, METRIC_LAST_SENT_TIME, REQUEST_RETRIES, @@ -35,12 +32,14 @@ SDK_NAME, SDK_VERSION, ) -from UnleashClient.events import ( - BaseEvent, - UnleashEvent, - UnleashEventType, - UnleashReadyEvent, +from UnleashClient.core import UnleashClientContract +from UnleashClient.core.client import ( + Evaluator, + RunState, + ExperimentalMode, + build_ready_callback, ) +from UnleashClient.events import BaseEvent from UnleashClient.periodic_tasks import ( aggregate_and_send_metrics, ) @@ -48,66 +47,11 @@ from .cache import BaseCache, FileCache from .utils import LOGGER, InstanceAllowType, InstanceCounter -try: - from typing import Literal, TypedDict -except ImportError: - from typing_extensions import Literal, TypedDict # type: ignore - INSTANCES = InstanceCounter() -_BASE_CONTEXT_FIELDS = [ - "userId", - "sessionId", - "environment", - "appName", - "currentTime", - "remoteAddress", - "properties", -] - - -class _RunState(IntEnum): - UNINITIALIZED = 0 - INITIALIZED = 1 - SHUTDOWN = 2 - - -class ExperimentalMode(TypedDict, total=False): - type: Literal["streaming", "polling"] - - -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 - - 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: +class UnleashClient(UnleashClientContract): """ A client for the Unleash feature toggle system. @@ -222,7 +166,7 @@ def __init__( self.strategy_mapping = {**custom_strategies} # Client status - self._run_state = _RunState.UNINITIALIZED + self._run_state = RunState.UNINITIALIZED # Bootstrapping if self.unleash_bootstrapped: @@ -233,6 +177,14 @@ def __init__( self.connector: BaseConnector = None + self._evaluator = Evaluator( + engine=self.engine, + cache=self.cache, + static_context=self.unleash_static_context, + verbose_log_level=self.unleash_verbose_log_level, + event_callback=self.unleash_event_callback, + ) + def _init_scheduler( self, scheduler: Optional[BaseScheduler], scheduler_executor: Optional[str] ) -> None: @@ -271,7 +223,7 @@ def connection_id(self): @property def is_initialized(self): - return self._run_state == _RunState.INITIALIZED + return self._run_state == RunState.INITIALIZED def initialize_client(self, fetch_toggles: bool = True) -> None: """ @@ -301,7 +253,7 @@ def initialize_client(self, fetch_toggles: bool = True) -> None: """ # Only perform initialization steps if client is not initialized. with self._lifecycle_lock: - if self._closed.is_set() or self._run_state > _RunState.UNINITIALIZED: + if self._closed.is_set() or self._run_state > RunState.UNINITIALIZED: warnings.warn( "Attempted to initialize an Unleash Client instance that has already been initialized." ) @@ -407,7 +359,8 @@ def initialize_client(self, fetch_toggles: bool = True) -> None: if start_scheduler: self.unleash_scheduler.start() - self._run_state = _RunState.INITIALIZED + self._evaluator.mark_hydrated() + self._run_state = RunState.INITIALIZED except Exception as excep: # Log exceptions during initialization. is_initialized will remain false. @@ -431,12 +384,7 @@ def feature_definitions(self) -> dict: } } """ - - toggles = self.engine.list_known_toggles() - return { - toggle.name: {"type": toggle.type, "project": toggle.project} - for toggle in toggles - } + return self._evaluator.feature_definitions() def destroy(self) -> None: """ @@ -448,7 +396,7 @@ def destroy(self) -> None: if self._closed.is_set(): return self._closed.set() - self._run_state = _RunState.SHUTDOWN + self._run_state = RunState.SHUTDOWN if self.connector: self.connector.stop() @@ -481,17 +429,6 @@ def destroy(self) -> None: except Exception as exc: LOGGER.warning("Exception during cache teardown: %s", exc) - @staticmethod - def _get_fallback_value( - fallback_function: Callable, feature_name: str, context: dict - ) -> bool: - if fallback_function: - fallback_value = fallback_function(feature_name, context) - else: - fallback_value = False - - return fallback_value - # pylint: disable=broad-except def is_enabled( self, @@ -511,39 +448,10 @@ def is_enabled( :param fallback_function: Allows users to provide a custom function to set default value. :return: Feature flag result """ - context = self._safe_context(context) - feature_enabled = self.engine.is_enabled(feature_name, context) + return self._evaluator.is_enabled(feature_name, context, fallback_function) - if feature_enabled is None: - feature_enabled = self._get_fallback_value( - fallback_function, feature_name, context - ) - - self.engine.count_toggle(feature_name, feature_enabled) - try: - if ( - self.unleash_event_callback - and self.engine.should_emit_impression_event(feature_name) - ): - event = UnleashEvent( - event_type=UnleashEventType.FEATURE_FLAG, - event_id=uuid.uuid4(), - context=context, - enabled=feature_enabled, - feature_name=feature_name, - ) + # pylint: disable=broad-except - self.unleash_event_callback(event) - except Exception as excep: - LOGGER.log( - self.unleash_verbose_log_level, - "Error in event callback: %s", - excep, - ) - - return feature_enabled - - # pylint: disable=broad-except def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict: """ Checks if a feature toggle is enabled. If so, return variant. @@ -556,88 +464,7 @@ 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. """ - context = self._safe_context(context) - variant = self._resolve_variant(feature_name, context) - - if not variant: - if self.unleash_bootstrapped or self.is_initialized: - LOGGER.log( - self.unleash_verbose_log_level, - "Attempted to get feature flag/variation %s, but client wasn't initialized!", - feature_name, - ) - variant = DISABLED_VARIATION - - self.engine.count_variant(feature_name, variant["name"]) - self.engine.count_toggle(feature_name, variant["feature_enabled"]) - - if self.unleash_event_callback and self.engine.should_emit_impression_event( - feature_name - ): - try: - event = UnleashEvent( - event_type=UnleashEventType.VARIANT, - event_id=uuid.uuid4(), - context=context, - enabled=bool(variant["enabled"]), - feature_name=feature_name, - variant=str(variant["name"]), - ) - - self.unleash_event_callback(event) - except Exception as excep: - LOGGER.log( - self.unleash_verbose_log_level, - "Error in event callback: %s", - excep, - ) - - return variant - - def _safe_context(self, context) -> dict: - new_context: Dict[str, Any] = self.unleash_static_context.copy() - new_context.update(context or {}) - - if "currentTime" not in new_context: - new_context["currentTime"] = datetime.now(timezone.utc).isoformat() - - safe_properties = self._extract_properties(new_context) - safe_properties = { - k: self._safe_context_value(v) for k, v in safe_properties.items() - } - safe_context = { - k: self._safe_context_value(v) - for k, v in new_context.items() - if k != "properties" - } - - safe_context["properties"] = safe_properties - - return safe_context - - def _extract_properties(self, context: dict) -> dict: - properties = context.get("properties", {}) - extracted_fields = { - k: v for k, v in context.items() if k not in _BASE_CONTEXT_FIELDS - } - extracted_fields.update(properties) - return extracted_fields - - def _safe_context_value(self, value): - if isinstance(value, datetime): - return value.isoformat() - if isinstance(value, (int, float)): - return str(value) - return str(value) - - def _resolve_variant(self, feature_name: str, context: dict) -> dict: - """ - Resolves a feature variant. - """ - variant = self.engine.get_variant(feature_name, context) - if variant: - return {k: v for k, v in asdict(variant).items() if v is not None} - return None + return self._evaluator.get_variant(feature_name, context) def _do_instance_check(self, multiple_instance_mode): identifier = self.__get_identifier() diff --git a/UnleashClient/core/__init__.py b/UnleashClient/core/__init__.py new file mode 100644 index 00000000..a8d532b3 --- /dev/null +++ b/UnleashClient/core/__init__.py @@ -0,0 +1,3 @@ +# ruff: noqa: F401 +from .client import Evaluator +from .contracts import UnleashClientContract diff --git a/UnleashClient/core/client.py b/UnleashClient/core/client.py new file mode 100644 index 00000000..ff1c4fa8 --- /dev/null +++ b/UnleashClient/core/client.py @@ -0,0 +1,235 @@ +from collections.abc import Callable +from dataclasses import asdict +from enum import IntEnum +from typing import Any, Dict, Optional +import uuid +from typing_extensions import Literal +from datetime import datetime, timezone + +from UnleashClient.events import ( + BaseEvent, + UnleashEvent, + UnleashEventType, + UnleashReadyEvent, +) + +try: + from typing import Literal, TypedDict +except ImportError: + from typing_extensions import Literal, TypedDict # type: ignore + +from UnleashClient.constants import DISABLED_VARIATION +from UnleashClient.utils import LOGGER + +_BASE_CONTEXT_FIELDS = [ + "userId", + "sessionId", + "environment", + "appName", + "currentTime", + "remoteAddress", + "properties", +] + + +class RunState(IntEnum): + UNINITIALIZED = 0 + INITIALIZED = 1 + SHUTDOWN = 2 + + +class ExperimentalMode(TypedDict, total=False): + type: Literal["streaming", "polling"] + + +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 + + 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 + + +class Evaluator: + def __init__( + self, + engine, + cache, + static_context: Dict[str, Any], + verbose_log_level: int, + event_callback=Optional[Callable[[BaseEvent], None]], + ) -> None: + self.engine = engine + self.cache = cache + self.hydrated = self.cache.bootstrapped + self.unleash_static_context = static_context + self.unleash_event_callback = event_callback + self.unleash_verbose_log_level = verbose_log_level + + @staticmethod + def _get_fallback_value( + fallback_function: Callable, feature_name: str, context: dict + ) -> bool: + if fallback_function: + fallback_value = fallback_function(feature_name, context) + else: + fallback_value = False + + return fallback_value + + def mark_hydrated(self) -> None: + self.hydrated = True + + # pylint: disable=broad-except + def is_enabled( + self, + feature_name: str, + context: Optional[dict] = None, + fallback_function: Callable = None, + ) -> bool: + context = self._safe_context(context) + feature_enabled = self.engine.is_enabled(feature_name, context) + + if feature_enabled is None: + feature_enabled = self._get_fallback_value( + fallback_function, feature_name, context + ) + + self.engine.count_toggle(feature_name, feature_enabled) + try: + if ( + self.unleash_event_callback + and self.engine.should_emit_impression_event(feature_name) + ): + event = UnleashEvent( + event_type=UnleashEventType.FEATURE_FLAG, + event_id=uuid.uuid4(), + context=context, + enabled=feature_enabled, + feature_name=feature_name, + ) + + self.unleash_event_callback(event) + except Exception as excep: + LOGGER.log( + self.unleash_verbose_log_level, + "Error in event callback: %s", + excep, + ) + + return feature_enabled + + # pylint: disable=broad-except + def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict: + context = self._safe_context(context) + variant = self._resolve_variant(feature_name, context) + + if not variant: + if not self.hydrated: + LOGGER.log( + self.unleash_verbose_log_level, + "Attempted to get feature flag/variation %s, but client wasn't initialized!", + feature_name, + ) + variant = DISABLED_VARIATION + + self.engine.count_variant(feature_name, variant["name"]) + self.engine.count_toggle(feature_name, variant["feature_enabled"]) + + if self.unleash_event_callback and self.engine.should_emit_impression_event( + feature_name + ): + try: + event = UnleashEvent( + event_type=UnleashEventType.VARIANT, + event_id=uuid.uuid4(), + context=context, + enabled=bool(variant["enabled"]), + feature_name=feature_name, + variant=str(variant["name"]), + ) + + self.unleash_event_callback(event) + except Exception as excep: + LOGGER.log( + self.unleash_verbose_log_level, + "Error in event callback: %s", + excep, + ) + + return variant + + def feature_definitions(self) -> dict: + toggles = self.engine.list_known_toggles() + return { + toggle.name: {"type": toggle.type, "project": toggle.project} + for toggle in toggles + } + + def _safe_context(self, context) -> dict: + new_context: Dict[str, Any] = self.unleash_static_context.copy() + new_context.update(context or {}) + + if "currentTime" not in new_context: + new_context["currentTime"] = datetime.now(timezone.utc).isoformat() + + safe_properties = self._extract_properties(new_context) + safe_properties = { + k: self._safe_context_value(v) for k, v in safe_properties.items() + } + safe_context = { + k: self._safe_context_value(v) + for k, v in new_context.items() + if k != "properties" + } + + safe_context["properties"] = safe_properties + + return safe_context + + def _extract_properties(self, context: dict) -> dict: + properties = context.get("properties", {}) + extracted_fields = { + k: v for k, v in context.items() if k not in _BASE_CONTEXT_FIELDS + } + extracted_fields.update(properties) + return extracted_fields + + def _safe_context_value(self, value): + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)): + return str(value) + return str(value) + + def _resolve_variant(self, feature_name: str, context: dict) -> dict: + """ + Resolves a feature variant. + """ + variant = self.engine.get_variant(feature_name, context) + if variant: + return {k: v for k, v in asdict(variant).items() if v is not None} + return None diff --git a/UnleashClient/core/contracts.py b/UnleashClient/core/contracts.py new file mode 100644 index 00000000..a3ffe297 --- /dev/null +++ b/UnleashClient/core/contracts.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Optional + + +class UnleashClientContract(ABC): + """This is the public contract for Unleash clients. All implementers must adhere to this interface. While children + may expose other methods, these are the methods that a caller can always expect to be present. + """ + + @abstractmethod + def is_enabled( + self, + feature_name: str, + context: Optional[dict] = None, + fallback_function: Callable = None, + ) -> bool: + + pass + + @abstractmethod + def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict: + pass + + @abstractmethod + def feature_definitions(self) -> dict: + pass diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 69271a43..dbc184b5 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -1278,7 +1278,7 @@ def test_context_moves_properties_fields_to_properties(): context = {"myContext": "1234"} - assert "myContext" in unleash_client._safe_context(context)["properties"] + assert "myContext" in unleash_client._evaluator._safe_context(context)["properties"] unleash_client.destroy() @@ -1292,8 +1292,8 @@ def test_existing_properties_are_retained_when_custom_context_properties_are_in_ context = {"myContext": "1234", "properties": {"yourContext": "1234"}} - assert "myContext" in unleash_client._safe_context(context)["properties"] - assert "yourContext" in unleash_client._safe_context(context)["properties"] + assert "myContext" in unleash_client._evaluator._safe_context(context)["properties"] + assert "yourContext" in unleash_client._evaluator._safe_context(context)["properties"] unleash_client.destroy() @@ -1307,7 +1307,7 @@ def test_base_context_properties_are_retained_in_root(): context = {"userId": "1234"} - assert "userId" in unleash_client._safe_context(context) + assert "userId" in unleash_client._evaluator._safe_context(context) unleash_client.destroy() From 83a9cc68cc56bbb0882244682684beb91c8cb5a2 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Sat, 1 Nov 2025 10:31:43 +0200 Subject: [PATCH 2/5] chore: fix type hint typo --- UnleashClient/core/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnleashClient/core/client.py b/UnleashClient/core/client.py index ff1c4fa8..7a9c4be9 100644 --- a/UnleashClient/core/client.py +++ b/UnleashClient/core/client.py @@ -80,7 +80,7 @@ def __init__( cache, static_context: Dict[str, Any], verbose_log_level: int, - event_callback=Optional[Callable[[BaseEvent], None]], + event_callback: Optional[Callable[[BaseEvent], None]], ) -> None: self.engine = engine self.cache = cache From feb7883eb06154c66be2b425c136b71693efcc61 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:15:23 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- UnleashClient/__init__.py | 2 +- UnleashClient/core/client.py | 5 +++-- tests/unit_tests/test_client.py | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index 7f8e2b8f..5e8b6db1 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -35,8 +35,8 @@ from UnleashClient.core import UnleashClientContract from UnleashClient.core.client import ( Evaluator, - RunState, ExperimentalMode, + RunState, build_ready_callback, ) from UnleashClient.events import BaseEvent diff --git a/UnleashClient/core/client.py b/UnleashClient/core/client.py index 7a9c4be9..bbccee62 100644 --- a/UnleashClient/core/client.py +++ b/UnleashClient/core/client.py @@ -1,10 +1,11 @@ +import uuid from collections.abc import Callable from dataclasses import asdict +from datetime import datetime, timezone from enum import IntEnum from typing import Any, Dict, Optional -import uuid + from typing_extensions import Literal -from datetime import datetime, timezone from UnleashClient.events import ( BaseEvent, diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index dbc184b5..21107bec 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -1293,7 +1293,9 @@ def test_existing_properties_are_retained_when_custom_context_properties_are_in_ context = {"myContext": "1234", "properties": {"yourContext": "1234"}} assert "myContext" in unleash_client._evaluator._safe_context(context)["properties"] - assert "yourContext" in unleash_client._evaluator._safe_context(context)["properties"] + assert ( + "yourContext" in unleash_client._evaluator._safe_context(context)["properties"] + ) unleash_client.destroy() From 0ccfd8ed5756cddea85d50991cc425f044c45125 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Sat, 1 Nov 2025 11:24:24 +0200 Subject: [PATCH 4/5] chore: fix import --- UnleashClient/core/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/UnleashClient/core/client.py b/UnleashClient/core/client.py index 7a9c4be9..27cd4a2d 100644 --- a/UnleashClient/core/client.py +++ b/UnleashClient/core/client.py @@ -1,9 +1,8 @@ -from collections.abc import Callable +from typing import Callable from dataclasses import asdict from enum import IntEnum from typing import Any, Dict, Optional import uuid -from typing_extensions import Literal from datetime import datetime, timezone from UnleashClient.events import ( From dc30abea5a7d4e7804bf6539af388cd427692867 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:26:17 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- UnleashClient/core/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnleashClient/core/client.py b/UnleashClient/core/client.py index a729320d..1c3d7752 100644 --- a/UnleashClient/core/client.py +++ b/UnleashClient/core/client.py @@ -2,7 +2,7 @@ from dataclasses import asdict from datetime import datetime, timezone from enum import IntEnum -from typing import Any, Dict, Optional, Callable +from typing import Any, Callable, Dict, Optional from UnleashClient.events import ( BaseEvent,