diff --git a/CHANGES.txt b/CHANGES.txt index b792f39..fee4327 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,9 @@ CHANGES +1.1.0 (Feb 27 2026) +- Split SDK 10.5.1 remains supported. Provider lifecycle events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) require Split SDK 10.6.0 or later; on 10.5.1 the provider works as before without emitting those events. +- Provider now emits OpenFeature provider events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) when Split SDK 10.6+ fires ready/update/timeout. Event details include OpenFeature-friendly metadata (see docs/EVENTS_MAPPING.md). + 1.0.0 (Nov 10 2025) - BREAKING CHANGE: Passing the SplitClient object to Provider constructor is now only through the initialization context dictionary - BREAKING CHANGE: Provider will throw exception when ObjectDetail and ObjectValue evaluation is used, since it will attempt to parse the treatment as a JSON structure. diff --git a/README.md b/README.md index 32c717d..7f22f74 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ This Provider is designed to allow the use of OpenFeature with Split, the platform for controlled rollouts, serving features to your users via the Split feature flag to manage your complete customer experience. ## Compatibility -This SDK is compatible with Python 3.9 and higher. +- Python 3.9 and higher. +- **Split SDK**: [Split Python SDK](https://github.com/splitio/python-client) **10.5.1 or later**. Provider lifecycle events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) require **10.6.0 or later**; on 10.5.1 the provider works without emitting those events. ## Getting started @@ -13,7 +14,7 @@ This package replaces the previous `split-openfeature-provider` Python provider ### Pip Installation ```python -pip install split-openfeature-provider==1.0.0 +pip install split-openfeature-provider==1.1.0 ``` ### Configure it Below is a simple example that describes using the Split Provider. Please see the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api) for details on how to use the OpenFeature SDK. diff --git a/requirements.txt b/requirements.txt index 42865f3..d1362f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ openfeature_sdk==0.8.3 -splitio_client[cpphash,asyncio]==10.5.1 +splitio_client[cpphash,asyncio]>=10.5.1 diff --git a/split_openfeature_provider/split_client_wrapper.py b/split_openfeature_provider/split_client_wrapper.py index 048340a..04a4457 100644 --- a/split_openfeature_provider/split_client_wrapper.py +++ b/split_openfeature_provider/split_client_wrapper.py @@ -2,13 +2,23 @@ from splitio.exceptions import TimeoutException import logging +try: + from splitio.models.events import SdkEvent +except ImportError: + SdkEvent = None # type: ignore # Split < 10.6: no events API + _LOGGER = logging.getLogger(__name__) +# Sentinel for block_until_ready timeout (not a Split SdkEvent) +SPLIT_EVENT_BUR_TIMEOUT = "block_until_ready_timeout" + + class SplitClientWrapper(): def __init__(self, initial_context): self.sdk_ready = False self.split_client = None + self._event_receiver = None if not self._validate_context(initial_context): raise AttributeError() @@ -39,6 +49,7 @@ def __init__(self, initial_context): self.sdk_ready = True except TimeoutException: _LOGGER.debug("Split SDK timed out") + self._notify_receiver(SPLIT_EVENT_BUR_TIMEOUT, None) self.split_client = self._factory.client() @@ -46,6 +57,7 @@ async def create(self): if self._initial_context.get("SplitClient") != None: self.split_client = self._initial_context.get("SplitClient") self._factory = self.split_client._factory + await self._register_split_events_async() return try: @@ -54,8 +66,10 @@ async def create(self): self.sdk_ready = True except TimeoutException: _LOGGER.debug("Split SDK timed out") + self._notify_receiver(SPLIT_EVENT_BUR_TIMEOUT, None) self.split_client = self._factory.client() + await self._register_split_events_async() def is_sdk_ready(self): if self.sdk_ready: @@ -69,9 +83,59 @@ def is_sdk_ready(self): return self.sdk_ready + def set_event_receiver(self, receiver): + """Set the receiver that will be notified of Split SDK events (e.g. the provider).""" + self._event_receiver = receiver + + def register_for_split_events(self): + """Register for Split SDK events (SDK_READY, SDK_UPDATE). Pass the provider as receiver (or call set_event_receiver first).""" + self._register_split_events() + + def unregister_for_split_events(self): + """Stop receiving Split SDK events.""" + self._event_receiver = None + + def _notify_receiver(self, split_event, event_metadata): + if self._event_receiver is None: + _LOGGER.debug("Split event %s: no receiver registered", split_event) + return + try: + self._event_receiver._on_split_event(split_event, event_metadata) + except Exception as ex: + _LOGGER.debug("Split event callback error: %s", ex) + + def _register_split_events(self): + if self._factory is None: + _LOGGER.warning("SplitClientWrapper: _factory is None, cannot register for SDK events") + return + if SdkEvent is None: + _LOGGER.debug("SplitClientWrapper: SdkEvent not available (Split SDK < 10.6?), skipping event registration") + return + try: + em = self._factory._events_manager + if not hasattr(em, "register"): + _LOGGER.warning("SplitClientWrapper: events_manager has no register method") + return + em.register(SdkEvent.SDK_READY, lambda m: self._notify_receiver(SdkEvent.SDK_READY, m)) + em.register(SdkEvent.SDK_UPDATE, lambda m: self._notify_receiver(SdkEvent.SDK_UPDATE, m)) + _LOGGER.info("SplitClientWrapper: registered for SDK_READY and SDK_UPDATE") + except Exception as ex: + _LOGGER.warning("Could not register Split events: %s", ex) + def destroy(self, destroy_event=None): self._factory.destroy(destroy_event) + async def _register_split_events_async(self): + if self._factory is None or SdkEvent is None: + return + try: + em = self._factory._events_manager + if hasattr(em, "register"): + await em.register(SdkEvent.SDK_READY, lambda m: self._notify_receiver(SdkEvent.SDK_READY, m)) + await em.register(SdkEvent.SDK_UPDATE, lambda m: self._notify_receiver(SdkEvent.SDK_UPDATE, m)) + except Exception as ex: + _LOGGER.debug("Could not register Split events: %s", ex) + async def destroy_async(self): await self._factory.destroy() diff --git a/split_openfeature_provider/split_provider.py b/split_openfeature_provider/split_provider.py index e57bcbd..45ac2f5 100644 --- a/split_openfeature_provider/split_provider.py +++ b/split_openfeature_provider/split_provider.py @@ -7,15 +7,95 @@ from openfeature.exception import ErrorCode, GeneralError, ParseError, OpenFeatureError, TargetingKeyMissingError from openfeature.flag_evaluation import Reason, FlagResolutionDetails from openfeature.provider import AbstractProvider, Metadata -from split_openfeature_provider.split_client_wrapper import SplitClientWrapper +from openfeature.event import ProviderEventDetails +from split_openfeature_provider.split_client_wrapper import SplitClientWrapper, SPLIT_EVENT_BUR_TIMEOUT _LOGGER = logging.getLogger(__name__) +try: + from splitio.models.events import SdkEvent +except ImportError: + SdkEvent = None # type: ignore + + +def _flags_changed_from_sdk_update(event_metadata): + """ + Extract list of updated flag/split names from Split SDK_UPDATE event metadata. + OpenFeature expects flags_changed: list[str] for PROVIDER_CONFIGURATION_CHANGED. + Handles: dict with "names", object with .metadata, or object with get_names() (Split EventsMetadata). + """ + if event_metadata is None: + return None + if hasattr(event_metadata, "metadata") and getattr(event_metadata, "metadata", None) is not None: + event_metadata = getattr(event_metadata, "metadata") + if isinstance(event_metadata, dict): + val = event_metadata.get("names") + if isinstance(val, list): + return [str(x) for x in val if x is not None] + return None + if hasattr(event_metadata, "get_names"): + names = event_metadata.get_names() + if names is not None: + return [str(x) for x in names if x is not None] + return None + + +def _metadata_from_split(split_event, event_metadata): + """Build OpenFeature event metadata dict from Split event (and optional Split metadata).""" + meta = {"split_event": getattr(split_event, "value", str(split_event))} + if event_metadata is not None and isinstance(event_metadata, dict): + for k, v in event_metadata.items(): + if isinstance(v, (bool, str, int, float)): + meta["split_%s" % k] = v + # Split may pass an object with get_type/get_names (e.g. EventsMetadata) + if event_metadata is not None and hasattr(event_metadata, "get_type"): + t = event_metadata.get_type() + meta["split_type"] = getattr(t, "value", str(t)) + if event_metadata is not None and hasattr(event_metadata, "get_names"): + names = event_metadata.get_names() + meta["split_names"] = list(names) if names is not None else [] + return meta + + class SplitProviderBase(AbstractProvider): def get_metadata(self) -> Metadata: return Metadata("Split") + def attach(self, on_emit): + super().attach(on_emit) + self._split_client_wrapper.set_event_receiver(self) + self._split_client_wrapper.register_for_split_events() + + def detach(self): + self._split_client_wrapper.unregister_for_split_events() + super().detach() + + def _on_split_event(self, split_event, event_metadata): + """Map Split SDK events to OpenFeature provider events with OpenFeature-friendly details.""" + _LOGGER.debug("SplitProvider: _on_split_event received %s", split_event) + if split_event == SPLIT_EVENT_BUR_TIMEOUT: + self.emit_provider_error(ProviderEventDetails( + message="Block until ready timed out", + error_code=ErrorCode.PROVIDER_NOT_READY, + metadata=_metadata_from_split(split_event, event_metadata), + )) + return + if SdkEvent is None: + return + if split_event == SdkEvent.SDK_READY: + self.emit_provider_ready(ProviderEventDetails( + metadata=_metadata_from_split(split_event, event_metadata), + )) + elif split_event == SdkEvent.SDK_UPDATE: + flags_changed = _flags_changed_from_sdk_update(event_metadata) + details = ProviderEventDetails( + flags_changed=flags_changed, + metadata=_metadata_from_split(split_event, event_metadata), + ) + _LOGGER.info("SplitProvider: emitting PROVIDER_CONFIGURATION_CHANGED flags_changed=%s", flags_changed) + self.emit_provider_configuration_changed(details) + def get_provider_hooks(self) -> typing.List[Hook]: return []