From 0a5d473170ade67e2c3bde78184220f3a9b1ada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Pic=C3=B3?= Date: Mon, 27 Jul 2026 10:33:16 +0200 Subject: [PATCH 1/4] refactor: move `extract_environment_from_headers` to utils.py --- UnleashClient/__init__.py | 8 ++++-- UnleashClient/environment_resolver.py | 26 ------------------ UnleashClient/utils.py | 27 ++++++++++++++++++- ..._environment_resolver.py => test_utils.py} | 2 +- 4 files changed, 33 insertions(+), 30 deletions(-) delete mode 100644 UnleashClient/environment_resolver.py rename tests/unit_tests/{test_environment_resolver.py => test_utils.py} (91%) diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index d1cb8663..c972ac69 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -35,7 +35,6 @@ SDK_NAME, SDK_VERSION, ) -from UnleashClient.environment_resolver import extract_environment_from_headers from UnleashClient.events import ( BaseEvent, UnleashEvent, @@ -48,7 +47,12 @@ ) from .cache import BaseCache, FileCache -from .utils import LOGGER, InstanceAllowType, InstanceCounter +from .utils import ( + LOGGER, + InstanceAllowType, + InstanceCounter, + extract_environment_from_headers, +) try: from typing import Literal, TypedDict diff --git a/UnleashClient/environment_resolver.py b/UnleashClient/environment_resolver.py deleted file mode 100644 index 68722f1e..00000000 --- a/UnleashClient/environment_resolver.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Dict, Optional - - -def extract_environment_from_headers( - headers: Optional[Dict[str, str]], -) -> Optional[str]: - if not headers: - return None - - auth_key = next( - (key for key in headers if key.lower() == "authorization"), - None, - ) - if not auth_key: - return None - - auth_value = headers.get(auth_key) - if not auth_value: - return None - - _, sep, after_colon = auth_value.partition(":") - if not sep: - return None - - environment, _, _ = after_colon.partition(".") - return environment or None diff --git a/UnleashClient/utils.py b/UnleashClient/utils.py index 2dc403e4..cbeb42f4 100644 --- a/UnleashClient/utils.py +++ b/UnleashClient/utils.py @@ -1,7 +1,7 @@ import logging from enum import Enum from threading import RLock -from typing import Any +from typing import Any, Dict, Optional import mmh3 # pylint: disable=import-error from requests import Response @@ -67,3 +67,28 @@ def log_resp_info(resp: Response) -> None: LOGGER.debug("HTTP status code: %s", resp.status_code) LOGGER.debug("HTTP headers: %s", resp.headers) LOGGER.debug("HTTP content: %s", resp.text) + + +def extract_environment_from_headers( + headers: Optional[Dict[str, str]], +) -> Optional[str]: + if not headers: + return None + + auth_key = next( + (key for key in headers if key.lower() == "authorization"), + None, + ) + if not auth_key: + return None + + auth_value = headers.get(auth_key) + if not auth_value: + return None + + _, sep, after_colon = auth_value.partition(":") + if not sep: + return None + + environment, _, _ = after_colon.partition(".") + return environment or None diff --git a/tests/unit_tests/test_environment_resolver.py b/tests/unit_tests/test_utils.py similarity index 91% rename from tests/unit_tests/test_environment_resolver.py rename to tests/unit_tests/test_utils.py index 1f31b11e..c1c1b2ec 100644 --- a/tests/unit_tests/test_environment_resolver.py +++ b/tests/unit_tests/test_utils.py @@ -1,4 +1,4 @@ -from UnleashClient.environment_resolver import extract_environment_from_headers +from UnleashClient.utils import extract_environment_from_headers def test_valid_headers(): From ebc1284f0766c93f798b768d4b1182406bff9527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Pic=C3=B3?= Date: Mon, 27 Jul 2026 11:05:56 +0200 Subject: [PATCH 2/4] refactor: create a new `/clients` directory As part of #240, this change makes way for a new /clients directory. that is where the new base class for clients and the async implementation of the client will live. --- UnleashClient/__init__.py | 706 +----------------- UnleashClient/clients/__init__.py | 9 + UnleashClient/clients/unleash_client.py | 698 +++++++++++++++++ tests/unit_tests/clients/__init__.py | 0 .../{ => clients}/test_custom_strategy.py | 0 .../test_unleash_client.py} | 1 + .../test_unleash_client_streaming.py} | 0 7 files changed, 716 insertions(+), 698 deletions(-) create mode 100644 UnleashClient/clients/__init__.py create mode 100644 UnleashClient/clients/unleash_client.py create mode 100644 tests/unit_tests/clients/__init__.py rename tests/unit_tests/{ => clients}/test_custom_strategy.py (100%) rename tests/unit_tests/{test_client.py => clients/test_unleash_client.py} (99%) rename tests/unit_tests/{test_client_streaming.py => clients/test_unleash_client_streaming.py} (100%) diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index c972ac69..905da547 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -1,699 +1,9 @@ -# pylint: disable=invalid-name -import random -import string -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 apscheduler.executors.pool import ThreadPoolExecutor -from apscheduler.job import Job -from apscheduler.jobstores.base import JobLookupError -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.schedulers.base import STATE_RUNNING, BaseScheduler -from apscheduler.triggers.interval import IntervalTrigger -from yggdrasil_engine.engine import UnleashEngine - -from UnleashClient.api import register_client -from UnleashClient.connectors import ( - BaseConnector, - BootstrapConnector, - OfflineConnector, - PollingConnector, - StreamingConnector, +# ruff: noqa: F401 +from .clients import ( + _BASE_CONTEXT_FIELDS, + INSTANCES, + ExperimentalMode, + UnleashClient, + _RunState, + build_ready_callback, ) -from UnleashClient.constants import ( - APPLICATION_HEADERS, - DISABLED_VARIATION, - ETAG, - METRIC_LAST_SENT_TIME, - REQUEST_RETRIES, - REQUEST_TIMEOUT, - SDK_NAME, - SDK_VERSION, -) -from UnleashClient.events import ( - BaseEvent, - UnleashEvent, - UnleashEventType, - UnleashReadyEvent, -) -from UnleashClient.impact_metrics import ImpactMetrics -from UnleashClient.periodic_tasks import ( - aggregate_and_send_metrics, -) - -from .cache import BaseCache, FileCache -from .utils import ( - LOGGER, - InstanceAllowType, - InstanceCounter, - extract_environment_from_headers, -) - -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: - """ - A client for the Unleash feature toggle system. - - :param url: URL of the unleash server, required. - :param app_name: Name of the application using the unleash client, required. - :param environment: Name of the environment using the unleash client, optional & defaults to "default". - :param instance_id: Unique identifier for unleash client instance, optional & defaults to "unleash-python-sdk" - :param refresh_interval: Provisioning refresh interval in seconds, optional & defaults to 15 seconds - :params request_timeout: Timeout for requests to unleash server in seconds, optional & defaults to 30 seconds - :params request_retries: Number of retries for requests to unleash server, optional & defaults to 3 - :param refresh_jitter: Provisioning refresh interval jitter in seconds, optional & defaults to None - :param metrics_interval: Metrics refresh interval in seconds, optional & defaults to 60 seconds - :param metrics_jitter: Metrics refresh interval jitter in seconds, optional & defaults to None - :param disable_metrics: Disables sending metrics to unleash server, optional & defaults to false. - :param disable_registration: Disables registration with unleash server, optional & defaults to false. - :param custom_headers: Default headers to send to unleash server, optional & defaults to empty. - :param custom_options: Default requests parameters, optional & defaults to empty. Can be used to skip SSL verification. - :param custom_strategies: Dictionary of custom strategy names : custom strategy objects. - :param cache_directory: Location of the cache directory. When unset, FCache will determine the location. - :param verbose_log_level: Numerical log level (https://docs.python.org/3/library/logging.html#logging-levels) for cases where checking a feature flag fails. - :param cache: Custom cache implementation that extends UnleashClient.cache.BaseCache. When unset, UnleashClient will use Fcache. - :param scheduler: Custom APScheduler object. Use this if you want to customize jobstore or executors. When unset, UnleashClient will create it's own scheduler. - :param scheduler_executor: Name of APSCheduler executor to use if using a custom scheduler. - :param multiple_instance_mode: Determines how multiple instances being instantiated is handled by the SDK, when set to InstanceAllowType.BLOCK, the client constructor will fail when more than one instance is detected, when set to InstanceAllowType.WARN, multiple instances will be allowed but log a warning, when set to InstanceAllowType.SILENTLY_ALLOW, no warning or failure will be raised when instantiating multiple instances of the client. Defaults to InstanceAllowType.WARN - :param event_callback: Function to call if impression events are enabled. WARNING: Depending on your event library, this may have performance implications! - :param experimental_mode: Optional dict to configure mode. Use {"type": "streaming"} to enable streaming or {"type": "polling"} (default). - :param sdk_flavor: Optional identifier of an integration built on top of this SDK (e.g. an OpenFeature provider). Sent in the register + metrics payloads alongside sdkVersion so adoption of the integration can be tracked. Leave unset for plain SDK usage. - :param sdk_flavor_version: Optional version of the integration named by sdk_flavor. - """ - - def __init__( # noqa: PLR0913, PLR0917 - self, - url: str, - app_name: str, - environment: str = "default", - instance_id: str = "unleash-python-sdk", - refresh_interval: int = 15, - refresh_jitter: Optional[int] = None, - metrics_interval: int = 60, - metrics_jitter: Optional[int] = None, - disable_metrics: bool = False, - disable_registration: bool = False, - custom_headers: Optional[dict] = None, - custom_options: Optional[dict] = None, - request_timeout: int = REQUEST_TIMEOUT, - request_retries: int = REQUEST_RETRIES, - custom_strategies: Optional[dict] = None, - cache_directory: Optional[str] = None, - project_name: Optional[str] = None, - verbose_log_level: int = 30, - cache: Optional[BaseCache] = None, - scheduler: Optional[BaseScheduler] = None, - scheduler_executor: Optional[str] = None, - multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN, - event_callback: Optional[Callable[[BaseEvent], None]] = None, - experimental_mode: Optional[ExperimentalMode] = None, - sdk_flavor: Optional[str] = None, - sdk_flavor_version: Optional[str] = None, - ) -> None: - custom_headers = custom_headers or {} - custom_options = custom_options or {} - custom_strategies = custom_strategies or {} - - # Configuration - self.unleash_url = url.rstrip("/") - self.unleash_app_name = app_name - self.unleash_environment = environment - self.unleash_instance_id = instance_id - self._connection_id = str(uuid.uuid4()) - self.unleash_refresh_interval = refresh_interval - self.unleash_request_timeout = request_timeout - self.unleash_request_retries = request_retries - self.unleash_refresh_jitter = ( - int(refresh_jitter) if refresh_jitter is not None else None - ) - self.unleash_metrics_interval = metrics_interval - self.unleash_metrics_jitter = ( - int(metrics_jitter) if metrics_jitter is not None else None - ) - self.unleash_disable_metrics = disable_metrics - self.unleash_disable_registration = disable_registration - self.unleash_sdk_flavor = sdk_flavor - self.unleash_sdk_flavor_version = sdk_flavor_version - self.unleash_custom_headers = custom_headers - self.unleash_custom_options = custom_options - self.unleash_static_context = { - "appName": self.unleash_app_name, - "environment": self.unleash_environment, - } - 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.connector_mode: ExperimentalMode = experimental_mode or {"type": "polling"} - self._lifecycle_lock = threading.RLock() - self._closed = threading.Event() - - self._do_instance_check(multiple_instance_mode) - - # Class objects - self.fl_job: Job = None - self.metric_job: Job = None - self.engine = UnleashEngine() - - impact_metrics_environment = self.unleash_environment - extracted_env = extract_environment_from_headers(self.unleash_custom_headers) - if extracted_env: - impact_metrics_environment = extracted_env - - self.impact_metrics = ImpactMetrics( - self.engine, self.unleash_app_name, impact_metrics_environment - ) - - self.cache = cache or FileCache( - self.unleash_app_name, directory=cache_directory - ) - self.cache.mset({METRIC_LAST_SENT_TIME: datetime.now(timezone.utc), ETAG: ""}) - self.unleash_bootstrapped = self.cache.bootstrapped - - self.metrics_headers: dict = {} - - self._init_scheduler(scheduler, scheduler_executor) - - if custom_strategies: - self.engine.register_custom_strategies(custom_strategies) - - self.strategy_mapping = {**custom_strategies} - - # Client status - self._run_state = _RunState.UNINITIALIZED - - # Bootstrapping - if self.unleash_bootstrapped: - BootstrapConnector( - engine=self.engine, - cache=self.cache, - ).start() - - self.connector: BaseConnector = None - - def _init_scheduler( - self, scheduler: Optional[BaseScheduler], scheduler_executor: Optional[str] - ) -> None: - """ - Scheduler bootstrapping - """ - # - Figure out the Unleash executor name. - if scheduler and scheduler_executor: - self.unleash_executor_name = scheduler_executor - elif scheduler and not scheduler_executor: - raise ValueError( - "If using a custom scheduler, you must specify a executor." - ) - else: - if not scheduler and scheduler_executor: - LOGGER.warning( - "scheduler_executor should only be used with a custom scheduler." - ) - - self.unleash_executor_name = f"unleash_executor_{''.join(random.choices(string.ascii_uppercase + string.digits, k=6))}" - - # Set up the scheduler. - if scheduler: - self.unleash_scheduler = scheduler - else: - executors = {self.unleash_executor_name: ThreadPoolExecutor()} - self.unleash_scheduler = BackgroundScheduler(executors=executors) - - @property - def unleash_metrics_interval_str_millis(self) -> str: - return str(self.unleash_metrics_interval * 1000) - - @property - def connection_id(self): - return self._connection_id - - @property - def is_initialized(self): - return self._run_state == _RunState.INITIALIZED - - def initialize_client(self, fetch_toggles: bool = True) -> None: - """ - Initializes client and starts communication with central unleash server(s). - - This kicks off: - - * Client registration - * Provisioning poll - * Stats poll - - If `fetch_toggles` is `False`, feature toggle polling will be turned off - and instead the client will only load features from the cache. This is - usually used to cater the multi-process setups, e.g. Django, Celery, - etc. - - This will raise an exception on registration if the URL is invalid. It is done automatically if called inside a context manager as in: - - .. code-block:: python - - with UnleashClient( - url="https://foo.bar", - app_name="myClient1", - instance_id="myinstanceid" - ) as client: - pass - """ - # Only perform initialization steps if client is not initialized. - with self._lifecycle_lock: - 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." - ) - return - try: - start_scheduler = False - base_headers = { - **self.unleash_custom_headers, - **APPLICATION_HEADERS, - "unleash-connection-id": self.connection_id, - "unleash-appname": self.unleash_app_name, - "unleash-instanceid": self.unleash_instance_id, - "unleash-sdk": f"{SDK_NAME}:{SDK_VERSION}", - } - - # Register app - if not self.unleash_disable_registration: - register_client( - self.unleash_url, - self.unleash_app_name, - self.unleash_instance_id, - self.connection_id, - self.unleash_metrics_interval, - base_headers, - self.unleash_custom_options, - self.strategy_mapping, - self.unleash_request_timeout, - self.unleash_sdk_flavor, - self.unleash_sdk_flavor_version, - ) - mode = self.connector_mode.get("type", "polling") - - if mode == "streaming" and fetch_toggles: - self.connector = StreamingConnector( - engine=self.engine, - cache=self.cache, - url=self.unleash_url, - headers=base_headers, - request_timeout=self.unleash_request_timeout, - ready_callback=self._ready_callback, - custom_options=self.unleash_custom_options, - ) - elif fetch_toggles: - start_scheduler = True - self.connector = PollingConnector( - engine=self.engine, - cache=self.cache, - scheduler=self.unleash_scheduler, - url=self.unleash_url, - app_name=self.unleash_app_name, - instance_id=self.unleash_instance_id, - headers=base_headers, - custom_options=self.unleash_custom_options, - request_timeout=self.unleash_request_timeout, - request_retries=self.unleash_request_retries, - project=self.unleash_project_name, - scheduler_executor=self.unleash_executor_name, - refresh_interval=self.unleash_refresh_interval, - event_callback=self.unleash_event_callback, - ready_callback=self._ready_callback, - ) - else: - start_scheduler = True - self.connector = OfflineConnector( - engine=self.engine, - cache=self.cache, - scheduler=self.unleash_scheduler, - scheduler_executor=self.unleash_executor_name, - refresh_interval=self.unleash_refresh_interval, - refresh_jitter=self.unleash_refresh_jitter, - ready_callback=self._ready_callback, - ) - - self.connector.start() - - if not self.unleash_disable_metrics: - if getattr(self.unleash_scheduler, "state", None) != STATE_RUNNING: - start_scheduler = True - - self.metrics_headers = { - **base_headers, - "unleash-interval": self.unleash_metrics_interval_str_millis, - } - - metrics_args = { - "url": self.unleash_url, - "app_name": self.unleash_app_name, - "connection_id": self.connection_id, - "instance_id": self.unleash_instance_id, - "headers": self.metrics_headers, - "custom_options": self.unleash_custom_options, - "request_timeout": self.unleash_request_timeout, - "engine": self.engine, - "sdk_flavor": self.unleash_sdk_flavor, - "sdk_flavor_version": self.unleash_sdk_flavor_version, - } - - self.metric_job = self.unleash_scheduler.add_job( - aggregate_and_send_metrics, - trigger=IntervalTrigger( - seconds=int(self.unleash_metrics_interval), - jitter=self.unleash_metrics_jitter, - ), - executor=self.unleash_executor_name, - kwargs=metrics_args, - ) - - if start_scheduler: - self.unleash_scheduler.start() - self._run_state = _RunState.INITIALIZED - - except Exception as excep: - # Log exceptions during initialization. is_initialized will remain false. - LOGGER.warning( - "Exception during UnleashClient initialization: %s", excep - ) - raise excep - - 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. - - Example response: - - { - "feature1": { - "project": "default", - "type": "release", - } - } - """ - - toggles = self.engine.list_known_toggles() - return { - toggle.name: {"type": toggle.type, "project": toggle.project} - for toggle in toggles - } - - def destroy(self) -> None: - """ - Gracefully shuts down the Unleash client by stopping jobs and stopping - the scheduler. - - For cache teardown: - - Default disk-backed FileCache instances are preserved on disk. - - Custom non-FileCache implementations will have destroy() called. - - You shouldn't need this too much! - """ - with self._lifecycle_lock: - if self._closed.is_set(): - return - self._closed.set() - self._run_state = _RunState.SHUTDOWN - if self.connector: - self.connector.stop() - - if self.metric_job: - # Flush metrics before shutting down. - aggregate_and_send_metrics( - url=self.unleash_url, - app_name=self.unleash_app_name, - connection_id=self.connection_id, - instance_id=self.unleash_instance_id, - headers=self.metrics_headers, - custom_options=self.unleash_custom_options, - request_timeout=self.unleash_request_timeout, - engine=self.engine, - ) - try: - self.metric_job.remove() - except JobLookupError as exc: - LOGGER.info("Exception during connector teardown: %s", exc) - - try: - if hasattr(self, "unleash_scheduler") and self.unleash_scheduler: - self.unleash_scheduler.remove_all_jobs() - self.unleash_scheduler.shutdown(wait=True) - except Exception as exc: - LOGGER.warning("Exception during scheduler teardown: %s", exc) - - # Disk-backed FileCache instances can be shared across processes. - # Avoid deleting them during shutdown to prevent cache races. - if not isinstance(self.cache, FileCache): - try: - self.cache.destroy() - 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, - feature_name: str, - context: Optional[dict] = None, - fallback_function: Callable = None, - ) -> bool: - """ - Checks if a feature toggle is enabled. - - Notes: - - * If client hasn't been initialized yet or an error occurs, flag will default to false. - - :param feature_name: Name of the feature - :param context: Dictionary with context (e.g. IPs, email) for feature toggle. - :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) - - 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: - """ - Checks if a feature toggle is enabled. If so, return variant. - - Notes: - - * If client hasn't been initialized yet or an error occurs, flag will default to false. - - :param feature_name: Name of the feature - :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 - - def _do_instance_check(self, multiple_instance_mode): - identifier = self.__get_identifier() - if identifier in INSTANCES: - msg = f"You already have {INSTANCES.count(identifier)} instance(s) configured for this config: {identifier}, please double check the code where this client is being instantiated." - if multiple_instance_mode == InstanceAllowType.BLOCK: - raise Exception(msg) # pylint: disable=broad-exception-raised - if multiple_instance_mode == InstanceAllowType.WARN: - LOGGER.error(msg) - INSTANCES.increment(identifier) - - def __get_identifier(self): - api_key = ( - self.unleash_custom_headers.get("Authorization") - if self.unleash_custom_headers is not None - else None - ) - return f"apiKey:{api_key} appName:{self.unleash_app_name} instanceId:{self.unleash_instance_id}" - - def __enter__(self) -> "UnleashClient": - self.initialize_client() - return self - - def __exit__(self, *args, **kwargs): - self.destroy() - return False diff --git a/UnleashClient/clients/__init__.py b/UnleashClient/clients/__init__.py new file mode 100644 index 00000000..d4e66c13 --- /dev/null +++ b/UnleashClient/clients/__init__.py @@ -0,0 +1,9 @@ +# ruff: noqa: F401 +from .unleash_client import ( + _BASE_CONTEXT_FIELDS, + INSTANCES, + ExperimentalMode, + UnleashClient, + _RunState, + build_ready_callback, +) diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py new file mode 100644 index 00000000..bcf2911d --- /dev/null +++ b/UnleashClient/clients/unleash_client.py @@ -0,0 +1,698 @@ +# pylint: disable=invalid-name +import random +import string +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 apscheduler.executors.pool import ThreadPoolExecutor +from apscheduler.job import Job +from apscheduler.jobstores.base import JobLookupError +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.schedulers.base import STATE_RUNNING, BaseScheduler +from apscheduler.triggers.interval import IntervalTrigger +from yggdrasil_engine.engine import UnleashEngine + +from UnleashClient.api import register_client +from UnleashClient.cache import BaseCache, FileCache +from UnleashClient.connectors import ( + BaseConnector, + BootstrapConnector, + OfflineConnector, + PollingConnector, + StreamingConnector, +) +from UnleashClient.constants import ( + APPLICATION_HEADERS, + DISABLED_VARIATION, + ETAG, + METRIC_LAST_SENT_TIME, + REQUEST_RETRIES, + REQUEST_TIMEOUT, + SDK_NAME, + SDK_VERSION, +) +from UnleashClient.events import ( + BaseEvent, + UnleashEvent, + UnleashEventType, + UnleashReadyEvent, +) +from UnleashClient.impact_metrics import ImpactMetrics +from UnleashClient.periodic_tasks import ( + aggregate_and_send_metrics, +) +from UnleashClient.utils import ( + LOGGER, + InstanceAllowType, + InstanceCounter, + extract_environment_from_headers, +) + +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: + """ + A client for the Unleash feature toggle system. + + :param url: URL of the unleash server, required. + :param app_name: Name of the application using the unleash client, required. + :param environment: Name of the environment using the unleash client, optional & defaults to "default". + :param instance_id: Unique identifier for unleash client instance, optional & defaults to "unleash-python-sdk" + :param refresh_interval: Provisioning refresh interval in seconds, optional & defaults to 15 seconds + :params request_timeout: Timeout for requests to unleash server in seconds, optional & defaults to 30 seconds + :params request_retries: Number of retries for requests to unleash server, optional & defaults to 3 + :param refresh_jitter: Provisioning refresh interval jitter in seconds, optional & defaults to None + :param metrics_interval: Metrics refresh interval in seconds, optional & defaults to 60 seconds + :param metrics_jitter: Metrics refresh interval jitter in seconds, optional & defaults to None + :param disable_metrics: Disables sending metrics to unleash server, optional & defaults to false. + :param disable_registration: Disables registration with unleash server, optional & defaults to false. + :param custom_headers: Default headers to send to unleash server, optional & defaults to empty. + :param custom_options: Default requests parameters, optional & defaults to empty. Can be used to skip SSL verification. + :param custom_strategies: Dictionary of custom strategy names : custom strategy objects. + :param cache_directory: Location of the cache directory. When unset, FCache will determine the location. + :param verbose_log_level: Numerical log level (https://docs.python.org/3/library/logging.html#logging-levels) for cases where checking a feature flag fails. + :param cache: Custom cache implementation that extends UnleashClient.cache.BaseCache. When unset, UnleashClient will use Fcache. + :param scheduler: Custom APScheduler object. Use this if you want to customize jobstore or executors. When unset, UnleashClient will create it's own scheduler. + :param scheduler_executor: Name of APSCheduler executor to use if using a custom scheduler. + :param multiple_instance_mode: Determines how multiple instances being instantiated is handled by the SDK, when set to InstanceAllowType.BLOCK, the client constructor will fail when more than one instance is detected, when set to InstanceAllowType.WARN, multiple instances will be allowed but log a warning, when set to InstanceAllowType.SILENTLY_ALLOW, no warning or failure will be raised when instantiating multiple instances of the client. Defaults to InstanceAllowType.WARN + :param event_callback: Function to call if impression events are enabled. WARNING: Depending on your event library, this may have performance implications! + :param experimental_mode: Optional dict to configure mode. Use {"type": "streaming"} to enable streaming or {"type": "polling"} (default). + :param sdk_flavor: Optional identifier of an integration built on top of this SDK (e.g. an OpenFeature provider). Sent in the register + metrics payloads alongside sdkVersion so adoption of the integration can be tracked. Leave unset for plain SDK usage. + :param sdk_flavor_version: Optional version of the integration named by sdk_flavor. + """ + + def __init__( # noqa: PLR0913, PLR0917 + self, + url: str, + app_name: str, + environment: str = "default", + instance_id: str = "unleash-python-sdk", + refresh_interval: int = 15, + refresh_jitter: Optional[int] = None, + metrics_interval: int = 60, + metrics_jitter: Optional[int] = None, + disable_metrics: bool = False, + disable_registration: bool = False, + custom_headers: Optional[dict] = None, + custom_options: Optional[dict] = None, + request_timeout: int = REQUEST_TIMEOUT, + request_retries: int = REQUEST_RETRIES, + custom_strategies: Optional[dict] = None, + cache_directory: Optional[str] = None, + project_name: Optional[str] = None, + verbose_log_level: int = 30, + cache: Optional[BaseCache] = None, + scheduler: Optional[BaseScheduler] = None, + scheduler_executor: Optional[str] = None, + multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN, + event_callback: Optional[Callable[[BaseEvent], None]] = None, + experimental_mode: Optional[ExperimentalMode] = None, + sdk_flavor: Optional[str] = None, + sdk_flavor_version: Optional[str] = None, + ) -> None: + custom_headers = custom_headers or {} + custom_options = custom_options or {} + custom_strategies = custom_strategies or {} + + # Configuration + self.unleash_url = url.rstrip("/") + self.unleash_app_name = app_name + self.unleash_environment = environment + self.unleash_instance_id = instance_id + self._connection_id = str(uuid.uuid4()) + self.unleash_refresh_interval = refresh_interval + self.unleash_request_timeout = request_timeout + self.unleash_request_retries = request_retries + self.unleash_refresh_jitter = ( + int(refresh_jitter) if refresh_jitter is not None else None + ) + self.unleash_metrics_interval = metrics_interval + self.unleash_metrics_jitter = ( + int(metrics_jitter) if metrics_jitter is not None else None + ) + self.unleash_disable_metrics = disable_metrics + self.unleash_disable_registration = disable_registration + self.unleash_sdk_flavor = sdk_flavor + self.unleash_sdk_flavor_version = sdk_flavor_version + self.unleash_custom_headers = custom_headers + self.unleash_custom_options = custom_options + self.unleash_static_context = { + "appName": self.unleash_app_name, + "environment": self.unleash_environment, + } + 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.connector_mode: ExperimentalMode = experimental_mode or {"type": "polling"} + self._lifecycle_lock = threading.RLock() + self._closed = threading.Event() + + self._do_instance_check(multiple_instance_mode) + + # Class objects + self.fl_job: Job = None + self.metric_job: Job = None + self.engine = UnleashEngine() + + impact_metrics_environment = self.unleash_environment + extracted_env = extract_environment_from_headers(self.unleash_custom_headers) + if extracted_env: + impact_metrics_environment = extracted_env + + self.impact_metrics = ImpactMetrics( + self.engine, self.unleash_app_name, impact_metrics_environment + ) + + self.cache = cache or FileCache( + self.unleash_app_name, directory=cache_directory + ) + self.cache.mset({METRIC_LAST_SENT_TIME: datetime.now(timezone.utc), ETAG: ""}) + self.unleash_bootstrapped = self.cache.bootstrapped + + self.metrics_headers: dict = {} + + self._init_scheduler(scheduler, scheduler_executor) + + if custom_strategies: + self.engine.register_custom_strategies(custom_strategies) + + self.strategy_mapping = {**custom_strategies} + + # Client status + self._run_state = _RunState.UNINITIALIZED + + # Bootstrapping + if self.unleash_bootstrapped: + BootstrapConnector( + engine=self.engine, + cache=self.cache, + ).start() + + self.connector: BaseConnector = None + + def _init_scheduler( + self, scheduler: Optional[BaseScheduler], scheduler_executor: Optional[str] + ) -> None: + """ + Scheduler bootstrapping + """ + # - Figure out the Unleash executor name. + if scheduler and scheduler_executor: + self.unleash_executor_name = scheduler_executor + elif scheduler and not scheduler_executor: + raise ValueError( + "If using a custom scheduler, you must specify a executor." + ) + else: + if not scheduler and scheduler_executor: + LOGGER.warning( + "scheduler_executor should only be used with a custom scheduler." + ) + + self.unleash_executor_name = f"unleash_executor_{''.join(random.choices(string.ascii_uppercase + string.digits, k=6))}" + + # Set up the scheduler. + if scheduler: + self.unleash_scheduler = scheduler + else: + executors = {self.unleash_executor_name: ThreadPoolExecutor()} + self.unleash_scheduler = BackgroundScheduler(executors=executors) + + @property + def unleash_metrics_interval_str_millis(self) -> str: + return str(self.unleash_metrics_interval * 1000) + + @property + def connection_id(self): + return self._connection_id + + @property + def is_initialized(self): + return self._run_state == _RunState.INITIALIZED + + def initialize_client(self, fetch_toggles: bool = True) -> None: + """ + Initializes client and starts communication with central unleash server(s). + + This kicks off: + + * Client registration + * Provisioning poll + * Stats poll + + If `fetch_toggles` is `False`, feature toggle polling will be turned off + and instead the client will only load features from the cache. This is + usually used to cater the multi-process setups, e.g. Django, Celery, + etc. + + This will raise an exception on registration if the URL is invalid. It is done automatically if called inside a context manager as in: + + .. code-block:: python + + with UnleashClient( + url="https://foo.bar", + app_name="myClient1", + instance_id="myinstanceid" + ) as client: + pass + """ + # Only perform initialization steps if client is not initialized. + with self._lifecycle_lock: + 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." + ) + return + try: + start_scheduler = False + base_headers = { + **self.unleash_custom_headers, + **APPLICATION_HEADERS, + "unleash-connection-id": self.connection_id, + "unleash-appname": self.unleash_app_name, + "unleash-instanceid": self.unleash_instance_id, + "unleash-sdk": f"{SDK_NAME}:{SDK_VERSION}", + } + + # Register app + if not self.unleash_disable_registration: + register_client( + self.unleash_url, + self.unleash_app_name, + self.unleash_instance_id, + self.connection_id, + self.unleash_metrics_interval, + base_headers, + self.unleash_custom_options, + self.strategy_mapping, + self.unleash_request_timeout, + self.unleash_sdk_flavor, + self.unleash_sdk_flavor_version, + ) + mode = self.connector_mode.get("type", "polling") + + if mode == "streaming" and fetch_toggles: + self.connector = StreamingConnector( + engine=self.engine, + cache=self.cache, + url=self.unleash_url, + headers=base_headers, + request_timeout=self.unleash_request_timeout, + ready_callback=self._ready_callback, + custom_options=self.unleash_custom_options, + ) + elif fetch_toggles: + start_scheduler = True + self.connector = PollingConnector( + engine=self.engine, + cache=self.cache, + scheduler=self.unleash_scheduler, + url=self.unleash_url, + app_name=self.unleash_app_name, + instance_id=self.unleash_instance_id, + headers=base_headers, + custom_options=self.unleash_custom_options, + request_timeout=self.unleash_request_timeout, + request_retries=self.unleash_request_retries, + project=self.unleash_project_name, + scheduler_executor=self.unleash_executor_name, + refresh_interval=self.unleash_refresh_interval, + event_callback=self.unleash_event_callback, + ready_callback=self._ready_callback, + ) + else: + start_scheduler = True + self.connector = OfflineConnector( + engine=self.engine, + cache=self.cache, + scheduler=self.unleash_scheduler, + scheduler_executor=self.unleash_executor_name, + refresh_interval=self.unleash_refresh_interval, + refresh_jitter=self.unleash_refresh_jitter, + ready_callback=self._ready_callback, + ) + + self.connector.start() + + if not self.unleash_disable_metrics: + if getattr(self.unleash_scheduler, "state", None) != STATE_RUNNING: + start_scheduler = True + + self.metrics_headers = { + **base_headers, + "unleash-interval": self.unleash_metrics_interval_str_millis, + } + + metrics_args = { + "url": self.unleash_url, + "app_name": self.unleash_app_name, + "connection_id": self.connection_id, + "instance_id": self.unleash_instance_id, + "headers": self.metrics_headers, + "custom_options": self.unleash_custom_options, + "request_timeout": self.unleash_request_timeout, + "engine": self.engine, + "sdk_flavor": self.unleash_sdk_flavor, + "sdk_flavor_version": self.unleash_sdk_flavor_version, + } + + self.metric_job = self.unleash_scheduler.add_job( + aggregate_and_send_metrics, + trigger=IntervalTrigger( + seconds=int(self.unleash_metrics_interval), + jitter=self.unleash_metrics_jitter, + ), + executor=self.unleash_executor_name, + kwargs=metrics_args, + ) + + if start_scheduler: + self.unleash_scheduler.start() + self._run_state = _RunState.INITIALIZED + + except Exception as excep: + # Log exceptions during initialization. is_initialized will remain false. + LOGGER.warning( + "Exception during UnleashClient initialization: %s", excep + ) + raise excep + + 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. + + Example response: + + { + "feature1": { + "project": "default", + "type": "release", + } + } + """ + + toggles = self.engine.list_known_toggles() + return { + toggle.name: {"type": toggle.type, "project": toggle.project} + for toggle in toggles + } + + def destroy(self) -> None: + """ + Gracefully shuts down the Unleash client by stopping jobs and stopping + the scheduler. + + For cache teardown: + - Default disk-backed FileCache instances are preserved on disk. + - Custom non-FileCache implementations will have destroy() called. + + You shouldn't need this too much! + """ + with self._lifecycle_lock: + if self._closed.is_set(): + return + self._closed.set() + self._run_state = _RunState.SHUTDOWN + if self.connector: + self.connector.stop() + + if self.metric_job: + # Flush metrics before shutting down. + aggregate_and_send_metrics( + url=self.unleash_url, + app_name=self.unleash_app_name, + connection_id=self.connection_id, + instance_id=self.unleash_instance_id, + headers=self.metrics_headers, + custom_options=self.unleash_custom_options, + request_timeout=self.unleash_request_timeout, + engine=self.engine, + ) + try: + self.metric_job.remove() + except JobLookupError as exc: + LOGGER.info("Exception during connector teardown: %s", exc) + + try: + if hasattr(self, "unleash_scheduler") and self.unleash_scheduler: + self.unleash_scheduler.remove_all_jobs() + self.unleash_scheduler.shutdown(wait=True) + except Exception as exc: + LOGGER.warning("Exception during scheduler teardown: %s", exc) + + # Disk-backed FileCache instances can be shared across processes. + # Avoid deleting them during shutdown to prevent cache races. + if not isinstance(self.cache, FileCache): + try: + self.cache.destroy() + 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, + feature_name: str, + context: Optional[dict] = None, + fallback_function: Callable = None, + ) -> bool: + """ + Checks if a feature toggle is enabled. + + Notes: + + * If client hasn't been initialized yet or an error occurs, flag will default to false. + + :param feature_name: Name of the feature + :param context: Dictionary with context (e.g. IPs, email) for feature toggle. + :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) + + 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: + """ + Checks if a feature toggle is enabled. If so, return variant. + + Notes: + + * If client hasn't been initialized yet or an error occurs, flag will default to false. + + :param feature_name: Name of the feature + :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 + + def _do_instance_check(self, multiple_instance_mode): + identifier = self.__get_identifier() + if identifier in INSTANCES: + msg = f"You already have {INSTANCES.count(identifier)} instance(s) configured for this config: {identifier}, please double check the code where this client is being instantiated." + if multiple_instance_mode == InstanceAllowType.BLOCK: + raise Exception(msg) # pylint: disable=broad-exception-raised + if multiple_instance_mode == InstanceAllowType.WARN: + LOGGER.error(msg) + INSTANCES.increment(identifier) + + def __get_identifier(self): + api_key = ( + self.unleash_custom_headers.get("Authorization") + if self.unleash_custom_headers is not None + else None + ) + return f"apiKey:{api_key} appName:{self.unleash_app_name} instanceId:{self.unleash_instance_id}" + + def __enter__(self) -> "UnleashClient": + self.initialize_client() + return self + + def __exit__(self, *args, **kwargs): + self.destroy() + return False diff --git a/tests/unit_tests/clients/__init__.py b/tests/unit_tests/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/test_custom_strategy.py b/tests/unit_tests/clients/test_custom_strategy.py similarity index 100% rename from tests/unit_tests/test_custom_strategy.py rename to tests/unit_tests/clients/test_custom_strategy.py diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/clients/test_unleash_client.py similarity index 99% rename from tests/unit_tests/test_client.py rename to tests/unit_tests/clients/test_unleash_client.py index ce5154a9..007b891a 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/clients/test_unleash_client.py @@ -860,6 +860,7 @@ def test_uc_cache_bootstrap_file(cache): test_file = Path( Path(__file__).parent.resolve(), "..", + "..", "utilities", "mocks", "mock_bootstrap.json", diff --git a/tests/unit_tests/test_client_streaming.py b/tests/unit_tests/clients/test_unleash_client_streaming.py similarity index 100% rename from tests/unit_tests/test_client_streaming.py rename to tests/unit_tests/clients/test_unleash_client_streaming.py From 2b18e8a4946f743e70d5be8d64e06bf8bb5abd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Pic=C3=B3?= Date: Mon, 27 Jul 2026 12:12:19 +0200 Subject: [PATCH 3/4] refactor: do not export private elements There are not (and should not be) mentions of these elements outside of the module where they are declared. --- UnleashClient/__init__.py | 2 -- UnleashClient/clients/__init__.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index 905da547..018f0605 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -1,9 +1,7 @@ # ruff: noqa: F401 from .clients import ( - _BASE_CONTEXT_FIELDS, INSTANCES, ExperimentalMode, UnleashClient, - _RunState, build_ready_callback, ) diff --git a/UnleashClient/clients/__init__.py b/UnleashClient/clients/__init__.py index d4e66c13..7814fe48 100644 --- a/UnleashClient/clients/__init__.py +++ b/UnleashClient/clients/__init__.py @@ -1,9 +1,7 @@ # ruff: noqa: F401 from .unleash_client import ( - _BASE_CONTEXT_FIELDS, INSTANCES, ExperimentalMode, UnleashClient, - _RunState, build_ready_callback, ) From 58a391488890ad38c6df5c7a726c1b328e6d45f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Pic=C3=B3?= Date: Mon, 27 Jul 2026 14:56:41 +0200 Subject: [PATCH 4/4] fix: do not log API key in plain text on multiple instances warning --- UnleashClient/clients/unleash_client.py | 8 +++++- .../unit_tests/clients/test_unleash_client.py | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index bcf2911d..705add5b 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -513,6 +513,12 @@ def destroy(self) -> None: except Exception as exc: LOGGER.warning("Exception during cache teardown: %s", exc) + @staticmethod + def _redact_to_print_safely(value: Optional[str]) -> Optional[str]: + if not value: + return value + return f"{value[:6]}...{value[-3:]}" + @staticmethod def _get_fallback_value( fallback_function: Callable, feature_name: str, context: dict @@ -687,7 +693,7 @@ def __get_identifier(self): if self.unleash_custom_headers is not None else None ) - return f"apiKey:{api_key} appName:{self.unleash_app_name} instanceId:{self.unleash_instance_id}" + return f"apiKey:{self._redact_to_print_safely(api_key)} appName:{self.unleash_app_name} instanceId:{self.unleash_instance_id}" def __enter__(self) -> "UnleashClient": self.initialize_client() diff --git a/tests/unit_tests/clients/test_unleash_client.py b/tests/unit_tests/clients/test_unleash_client.py index 007b891a..1813cd63 100644 --- a/tests/unit_tests/clients/test_unleash_client.py +++ b/tests/unit_tests/clients/test_unleash_client.py @@ -1042,6 +1042,34 @@ def test_multiple_instances_are_unique_on_api_key(caplog): client2.destroy() +def test_redact_to_print_safely_truncates_middle(): + api_key = "abcdef1234567890ghijklmnop" + redacted = UnleashClient._redact_to_print_safely(api_key) + assert redacted == "abcdef...nop" + assert api_key not in redacted + + +def test_api_key_is_not_logged_in_plain_text_in_multiple_instances_warning(caplog): + api_key = "abcdef1234567890ghijklmnop" + client1 = UnleashClient( + URL, + APP_NAME, + custom_headers={"Authorization": api_key}, + ) + client2 = UnleashClient( + URL, + APP_NAME, + custom_headers={"Authorization": api_key}, + ) + + log_text = " ".join(str(r.msg) for r in caplog.records) + assert api_key not in log_text + assert "abcdef...nop" in log_text + + client1.destroy() + client2.destroy() + + @responses.activate def test_signals_feature_flag(cache): # Set up API