diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c6294d3 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,31 @@ +[run] +source = + splitio/ + +omit = + tests/* + */__init__.py + +branch = True + +relative_files = True + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + +precision = 2 diff --git a/.github/workflows/CODEOWNERS b/.github/workflows/CODEOWNERS new file mode 100644 index 0000000..ab53a7c --- /dev/null +++ b/.github/workflows/CODEOWNERS @@ -0,0 +1 @@ +* @splitio/sdk \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2598dd3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: ci +on: + push: + branches: + - main + - development + pull_request: + branches: + - main + - development + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + name: Test + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: '3.9.13' + + - name: Install dependencies + run: | + sudo apt update + sudo apt-get install -y libkrb5-dev + pip install -U setuptools pip wheel + pip install -e .[cpphash,redis,uwsgi] + pip install pytest --quiet + pip install mock + pip install pytest-asyncio + pip install -r requirements.txt + + - name: Run tests + run: cd tests; pytest -v + + - name: Set VERSION env + run: echo "VERSION=$(cat setup.py | grep "version=" | cut -d'"' -f2)" >> $GITHUB_ENV + + - name: SonarQube Scan (Push) + if: github.event_name == 'push' + uses: SonarSource/sonarcloud-github-action@v1.9 + env: + SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectBaseDir: . + args: > + -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} + -Dsonar.projectVersion=${{ env.VERSION }} + + - name: SonarQube Scan (Pull Request) + if: github.event_name == 'pull_request' + uses: SonarSource/sonarcloud-github-action@v1.9 + env: + SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectBaseDir: . + args: > + -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} + -Dsonar.projectVersion=${{ env.VERSION }} + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} + -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} + -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3efd499..9ce3aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # IDE .idea/ + +# Other +.DS_Store \ No newline at end of file diff --git a/CHANGES.txt b/CHANGES.txt index 389cae1..e9af5ea 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,15 @@ -0.0.1 -- First release. Up to date with spec 0.5.1 and python sdk 0.0.6 +Changes + +1.0.0 (Nov 5 2026) +- 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. +- Upgraded Split SDK to 10.5.1 +- Upgraded OpenFeature SDK to 0.8.3 +- Added support for asyncio mode +- Added ability to pass Ready Timeout and ConfigurationOptions to Provider initialization + 0.1.0 - Up to date with spec 0.8.0 and python sdk 0.8.1. Using split client 10.2.0 + +0.0.1 +- First release. Up to date with spec 0.5.1 and python sdk 0.0.6 diff --git a/LICENSE b/LICENSE index 051b5fd..df08de3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright © 2022 Split Software, Inc. +Copyright © 2025 Split Software, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 4744fc8..3a5b1f9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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 and higher. +This SDK is compatible with Python 3.9 and higher. ## Getting started ### Pip Installation @@ -18,21 +18,28 @@ Below is a simple example that describes using the Split Provider. Please see th ```python from openfeature import api from split_openfeature import SplitProvider - -api.set_provider(SplitProvider(api_key="YOUR_API_KEY")) +config = { + 'impressionsMode': 'OPTIMIZED', + 'impressionsRefreshRate': 30, + } +provider = SplitProvider({"SdkKey": "YOUR_API_KEY", "ConfigOptions": config, "ReadyBlockTime": 5}) +api.set_provider(provider) ``` -If you are more familiar with Split or want access to other initialization options, you can provide a Split `client` to the constructor. See the [Split Java SDK Documentation](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) for more information. +If you are more familiar with Split or want access to other initialization options, you can provide a Split `client` to the constructor. See the [Harness Split Python SDK Documentation](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/python-sdk/) for more information. ```python from openfeature import api from split_openfeature import SplitProvider from splitio import get_factory -factory = get_factory("YOUR_API_KEY", config=config_file) +config = { + 'impressionsMode': 'OPTIMIZED', + 'impressionsRefreshRate': 30, + } +factory = get_factory("YOUR_API_KEY", config=config) factory.block_until_ready(5) -api.set_provider(SplitProvider(client=factory.client())) +api.set_provider(SplitProvider({"SplitClient": factory.client()})) ``` -where config_file is the Split config file you want to use ## Use of OpenFeature with Split After the initial setup you can use OpenFeature according to their [documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/). @@ -56,9 +63,76 @@ or at the OpenFeatureAPI level ```python context = EvaluationContext(targeting_key="TARGETING_KEY") api.set_evaluation_context(context) -```` +``` If the context was set at the client or api level, it is not required to provide it during flag evaluation. +### Asyncio mode +The provider supports asyncio mode as well, using the asyncio mode in Split SDK. +Example below shows using the provider in asyncio + +```python +from openfeature import api +from split_openfeature import SplitProviderAsync +config = { + 'impressionsMode': 'OPTIMIZED', + 'impressionsRefreshRate': 30, + } +provider = SplitProvider({"SdkKey": "YOUR_API_KEY", "ConfigOptions": config, "ReadyBlockTime": 5}) +await provider.create() +api.set_provider(provider) +``` + +Example below show how to create the Split Client externally and pass it to Provider +```python +from openfeature import api +from split_openfeature import SplitProviderAsync +from splitio import get_factory_async + +config = { + 'impressionsMode': 'OPTIMIZED', + 'impressionsRefreshRate': 30, + } +factory = get_factory_async("YOUR_API_KEY", config=config) +await factory.block_until_ready(5) +provider = SplitProviderAsync({"SplitClient": factory.client()}) +await provider.create() +api.set_provider(provider) +``` + +Example below fetching the treatment in asyncio mode +```python +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +client = api.get_client("CLIENT_NAME") + +context = EvaluationContext(targeting_key="TARGETING_KEY") +value = await client.get_boolean_value_async("FLAG_NAME", False, context) +``` +### Logging +Split Provider use `logging` library, Each module has it's own logger, the root being split_provider. Below is an example of simple usage which will set all libraries using `logging` including the provider, to use `DEBUG` mode. +```python +import logging + +logging.basicConfig(level=logging.DEBUG) +``` + +### Shutting down Split SDK factory +Currently OpenFeature SDK does not provide override for provider shutdown, when using internal split client object, the Split SDK will not shutdown properly. We recommend using the example below before terminating the OpenFeature object + +```python +from threading import Event + +destroy_event = Event() +provider._split_client_wrapper._factory.destroy(destroy_event) +destroy_event.wait() +``` + +Below the example for asyncio mode +```python +await provider._split_client_wrapper._factory.destroy() +``` + ## Submitting issues The Split team monitors all issues submitted to this [issue tracker](https://github.com/splitio/split-openfeature-provider-python/issues). We encourage you to use this issue tracker to submit any bug reports, feedback, and feature enhancements. We'll do our best to respond in a timely manner. diff --git a/requirements.txt b/requirements.txt index af447bf..b4ddeda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -openfeature_sdk==0.8.1 -splitio_client==10.2.0 +openfeature_sdk==0.8.3 +splitio_client[cpphash,asyncio]==10.5.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 1bf7d6f..bd05f97 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,7 @@ setuptools.setup( name="split_openfeature", - version="0.1.0", - author="Robert Grassian", - author_email="robert.grassian@split.io", + version="1.0.0", description="The official Python Split Provider for OpenFeature", long_description=long_description, long_description_content_type="text/markdown", @@ -17,5 +15,5 @@ "Programming Language :: Python :: 3", 'Topic :: Software Development :: Libraries' ], - python_requires='>=3.5' + python_requires='>=3.9' ) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..6bfbc8c --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,10 @@ +sonar.projectName=split_openfeature +sonar.projectKey=split_openfeature +sonar.python.version=3.9 +sonar.sources=split_openfeature +sonar.tests=tests +sonar.text.excluded.file.suffixes=.csv +sonar.python.coverage.reportPaths=coverage.xml +sonar.coverage.exclusions=**/__init__.py +sonar.links.ci=https://github.com/splitio/split-openfeature-provider-python +sonar.links.scm=https://github.com/splitio/split-openfeature-provider-python/actions diff --git a/split_openfeature/__init__.py b/split_openfeature/__init__.py index 29aab60..e63e61e 100644 --- a/split_openfeature/__init__.py +++ b/split_openfeature/__init__.py @@ -1 +1,3 @@ -from split_openfeature.split_provider import SplitProvider +from split_openfeature.split_provider import SplitProvider, SplitProviderAsync +from split_openfeature.split_client_wrapper import SplitClientWrapper + diff --git a/split_openfeature/split_client_wrapper.py b/split_openfeature/split_client_wrapper.py new file mode 100644 index 0000000..df740a5 --- /dev/null +++ b/split_openfeature/split_client_wrapper.py @@ -0,0 +1,107 @@ +from splitio import get_factory, get_factory_async +from splitio.exceptions import TimeoutException +import logging + +_LOGGER = logging.getLogger(__name__) + +class SplitClientWrapper(): + + def __init__(self, initial_context): + self.sdk_ready = False + self.split_client = None + + if not self._validate_context(initial_context): + raise AttributeError() + + self._api_key = initial_context.get("SdkKey") + self._config = {} + if initial_context.get("ConfigOptions") != None: + self._config = initial_context.get("ConfigOptions") + + self._ready_block_time = 10 + if initial_context.get("ReadyBlockTime") != None: + self._ready_block_time = initial_context.get("ReadyBlockTime") + + if initial_context.get("ThreadingMode") != None: + self._threading_mode = initial_context.get("ThreadingMode") + if self._threading_mode == "asyncio": + self._initial_context = initial_context + return + + if initial_context.get("SplitClient") != None: + self.split_client = initial_context.get("SplitClient") + self._factory = self.split_client._factory + return + + try: + self._factory = get_factory(self._api_key, config=self._config) + self._factory.block_until_ready(self._ready_block_time) + self.sdk_ready = True + except TimeoutException: + _LOGGER.debug("Split SDK timed out") + + self.split_client = self._factory.client() + + 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 + return + + try: + self._factory = await get_factory_async(self._api_key, config=self._config) + await self._factory.block_until_ready(self._ready_block_time) + self.sdk_ready = True + except TimeoutException: + _LOGGER.debug("Split SDK timed out") + + self.split_client = self._factory.client() + + def is_sdk_ready(self): + if self.sdk_ready: + return True + + try: + self._factory.block_until_ready(0.1) + self.sdk_ready = True + except TimeoutException: + _LOGGER.debug("Split SDK timed out") + + return self.sdk_ready + + def destroy(self, destroy_event=None): + self._factory.destroy(destroy_event) + + async def destroy_async(self): + await self._factory.destroy() + + async def is_sdk_ready_async(self): + if self.sdk_ready: + return True + + try: + await self._factory.block_until_ready(0.1) + self.sdk_ready = True + except TimeoutException: + _LOGGER.debug("Split SDK timed out") + + return self.sdk_ready + + def _validate_context(self, initial_context): + if initial_context != None and not isinstance(initial_context, dict): + _LOGGER.error("SplitClientWrapper: initial_context must be of type `dict`") + return False + + if initial_context.get("SplitClient") == None and initial_context.get("SdkKey") == None: + _LOGGER.error("SplitClientWrapper: initial_context must contain keys `SplitClient` or `SdkKey`") + return False + + if initial_context.get("SdkKey") != None and not isinstance(initial_context.get("SdkKey"), str): + _LOGGER.error("SplitClientWrapper: key `SdkKey` must be of type `str`") + return False + + if initial_context.get("ConfigOptions") != None and not isinstance(initial_context.get("ConfigOptions"), dict): + _LOGGER.error("SplitClientWrapper: key `ConfigOptions` must be of type `dict`") + return False + + return True \ No newline at end of file diff --git a/split_openfeature/split_provider.py b/split_openfeature/split_provider.py index 3144d96..5c240b1 100644 --- a/split_openfeature/split_provider.py +++ b/split_openfeature/split_provider.py @@ -1,30 +1,17 @@ import typing -from json import JSONDecodeError +import logging +import json from openfeature.hook import Hook from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode, GeneralError, ParseError, OpenFeatureError, TargetingKeyMissingError from openfeature.flag_evaluation import Reason, FlagResolutionDetails from openfeature.provider import AbstractProvider, Metadata -from splitio import get_factory -from splitio.exceptions import TimeoutException -import json - +from split_openfeature.split_client_wrapper import SplitClientWrapper -class SplitProvider(AbstractProvider): +_LOGGER = logging.getLogger(__name__) - def __init__(self, api_key="", client=None): - if api_key == "" and client is None: - raise Exception("Must provide apiKey or Split Client") - if api_key != "": - factory = get_factory(api_key) - try: - factory.block_until_ready(1) - except TimeoutException: - raise GeneralError("Error occurred initializing the client.") - self.split_client = factory.client() - else: - self.split_client = client +class SplitProviderBase(AbstractProvider): def get_metadata(self) -> Metadata: return Metadata("Split") @@ -32,111 +19,188 @@ def get_metadata(self) -> Metadata: def get_provider_hooks(self) -> typing.List[Hook]: return [] - def resolve_boolean_details(self, flag_key: str, default_value: bool, - evaluation_context: EvaluationContext = EvaluationContext()): + def _evaluate_treatment(self, key: str, evaluation_context: EvaluationContext, default_value): + if evaluation_context is None: + raise GeneralError("Evaluation Context must be provided for the Split Provider") + + if not self._split_client_wrapper.is_sdk_ready(): + return SplitProvider.construct_flag_resolution(default_value, None, None, Reason.ERROR, + ErrorCode.PROVIDER_NOT_READY) + + targeting_key = evaluation_context.targeting_key + if not targeting_key: + raise TargetingKeyMissingError("Missing targeting key") + + attributes = SplitProvider.transform_context(evaluation_context) + evaluated = self._split_client_wrapper.split_client.get_treatment_with_config(targeting_key, key, attributes) + return self._process_treatment(evaluated, default_value) + + def _process_treatment(self, evaluated, default_value): try: - evaluated = self.evaluate_treatment(flag_key, evaluation_context) - if SplitProvider.no_treatment(evaluated): - return SplitProvider.construct_flag_resolution(default_value, evaluated, Reason.DEFAULT, + treatment = None + config = None + if evaluated != None: + treatment = evaluated[0] + config = evaluated[1] + + if SplitProvider.no_treatment(treatment) or treatment == "control": + return SplitProvider.construct_flag_resolution(default_value, treatment, None, Reason.DEFAULT, ErrorCode.FLAG_NOT_FOUND) - evaluated_lower = evaluated.lower() - if evaluated_lower in ["true", "on"]: - value = True - elif evaluated_lower in ["false", "off"]: - value = False - else: - raise ParseError("Could not convert treatment to boolean") - return SplitProvider.construct_flag_resolution(value, evaluated) - except OpenFeatureError: + value = treatment + try: + if type(default_value) is int: + value = int(treatment) + elif isinstance(default_value, float): + value = float(treatment) + elif isinstance(default_value, bool): + evaluated_lower = treatment.lower() + if evaluated_lower in ["true", "on"]: + value = True + elif evaluated_lower in ["false", "off"]: + value = False + else: + raise ParseError + elif isinstance(default_value, dict): + value = json.loads(treatment) + + except Exception: + raise ParseError + + return SplitProvider.construct_flag_resolution(value, treatment, config) + + except ParseError as ex: + _LOGGER.error("Evaluation Parse error") + _LOGGER.debug(ex) + raise ParseError("Could not convert treatment") + + except OpenFeatureError as ex: + _LOGGER.error("Evaluation OpenFeature Exception") + _LOGGER.debug(ex) raise - except Exception: - raise GeneralError("Error getting boolean evaluation") + + except Exception as ex: + _LOGGER.error("Evaluation Exception") + _LOGGER.debug(ex) + raise GeneralError("Failed to evaluate treatment") + + @staticmethod + def transform_context(evaluation_context: EvaluationContext): + return evaluation_context.attributes + @staticmethod + def no_treatment(treatment: str): + return not treatment or treatment == "control" + + @staticmethod + def construct_flag_resolution(value, variant, config, reason: Reason = Reason.TARGETING_MATCH, + error_code: ErrorCode = None): + return FlagResolutionDetails(value=value, error_code=error_code, reason=reason, variant=variant, + flag_metadata={"config": config}) + + def resolve_boolean_details(self, flag_key: str, default_value: bool, + evaluation_context: EvaluationContext = EvaluationContext()): + pass + def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: EvaluationContext = EvaluationContext()): - try: - evaluated = self.evaluate_treatment(flag_key, evaluation_context) - if SplitProvider.no_treatment(evaluated): - return SplitProvider.construct_flag_resolution(default_value, evaluated, Reason.DEFAULT, - ErrorCode.FLAG_NOT_FOUND) - return SplitProvider.construct_flag_resolution(evaluated, evaluated) - except OpenFeatureError: - raise - except Exception: - raise GeneralError("Error getting boolean evaluation") + pass def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: EvaluationContext = EvaluationContext()): - try: - evaluated = self.evaluate_treatment(flag_key, evaluation_context) - if SplitProvider.no_treatment(evaluated): - return SplitProvider.construct_flag_resolution(default_value, evaluated, Reason.DEFAULT, - ErrorCode.FLAG_NOT_FOUND) - try: - value = int(evaluated) - except ValueError: - raise ParseError("Could not convert treatment to integer") - return SplitProvider.construct_flag_resolution(value, evaluated) - except OpenFeatureError: - raise - except Exception: - raise GeneralError("Error getting boolean evaluation") + pass def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: EvaluationContext = EvaluationContext()): - try: - evaluated = self.evaluate_treatment(flag_key, evaluation_context) - if SplitProvider.no_treatment(evaluated): - return SplitProvider.construct_flag_resolution(default_value, evaluated, Reason.DEFAULT, - ErrorCode.FLAG_NOT_FOUND) - try: - value = float(evaluated) - except ValueError: - raise ParseError("Could not convert treatment to float") - return SplitProvider.construct_flag_resolution(value, evaluated) - except OpenFeatureError: - raise - except Exception: - raise GeneralError("Error getting boolean evaluation") + pass def resolve_object_details(self, flag_key: str, default_value: dict, evaluation_context: EvaluationContext = EvaluationContext()): - try: - evaluated = self.evaluate_treatment(flag_key, evaluation_context) - if SplitProvider.no_treatment(evaluated): - return SplitProvider.construct_flag_resolution(default_value, evaluated, Reason.DEFAULT, - ErrorCode.FLAG_NOT_FOUND) - value = json.loads(evaluated) - return SplitProvider.construct_flag_resolution(value, evaluated) - except JSONDecodeError: - raise ParseError("Could not convert treatment to dict") - except OpenFeatureError: - raise - except Exception: - raise GeneralError("Error getting boolean evaluation") + pass + + async def resolve_boolean_details_async(self, flag_key: str, default_value: bool, + evaluation_context: EvaluationContext = EvaluationContext()): + pass - # *** --- Helpers --- *** + async def resolve_string_details_async(self, flag_key: str, default_value: str, + evaluation_context: EvaluationContext = EvaluationContext()): + pass + async def resolve_integer_details_async(self, flag_key: str, default_value: int, + evaluation_context: EvaluationContext = EvaluationContext()): + pass + + async def resolve_float_details_async(self, flag_key: str, default_value: float, + evaluation_context: EvaluationContext = EvaluationContext()): + pass + + async def resolve_object_details_async(self, flag_key: str, default_value: dict, + evaluation_context: EvaluationContext = EvaluationContext()): + pass + +class SplitProvider(SplitProviderBase): + def __init__(self, initial_context): + self._split_client_wrapper = SplitClientWrapper(initial_context) - def evaluate_treatment(self, key: str, evaluation_context: EvaluationContext): + def resolve_boolean_details(self, flag_key: str, default_value: bool, + evaluation_context: EvaluationContext = EvaluationContext()): + return self._evaluate_treatment(flag_key, evaluation_context, default_value) + + def resolve_string_details(self, flag_key: str, default_value: str, + evaluation_context: EvaluationContext = EvaluationContext()): + return self._evaluate_treatment(flag_key, evaluation_context, default_value) + + def resolve_integer_details(self, flag_key: str, default_value: int, + evaluation_context: EvaluationContext = EvaluationContext()): + return self._evaluate_treatment(flag_key, evaluation_context, default_value) + + def resolve_float_details(self, flag_key: str, default_value: float, + evaluation_context: EvaluationContext = EvaluationContext()): + return self._evaluate_treatment(flag_key, evaluation_context, default_value) + + def resolve_object_details(self, flag_key: str, default_value: dict, + evaluation_context: EvaluationContext = EvaluationContext()): + return self._evaluate_treatment(flag_key, evaluation_context, default_value) + +class SplitProviderAsync(SplitProviderBase): + def __init__(self, initial_context): + if isinstance(initial_context, dict): + initial_context["ThreadingMode"] = "asyncio" + self._split_client_wrapper = SplitClientWrapper(initial_context) + + async def create(self): + await self._split_client_wrapper.create() + + async def resolve_boolean_details_async(self, flag_key: str, default_value: bool, + evaluation_context: EvaluationContext = EvaluationContext()): + return await self._evaluate_treatment_async(flag_key, evaluation_context, default_value) + + async def resolve_string_details_async(self, flag_key: str, default_value: str, + evaluation_context: EvaluationContext = EvaluationContext()): + return await self._evaluate_treatment_async(flag_key, evaluation_context, default_value) + + async def resolve_integer_details_async(self, flag_key: str, default_value: int, + evaluation_context: EvaluationContext = EvaluationContext()): + return await self._evaluate_treatment_async(flag_key, evaluation_context, default_value) + + async def resolve_float_details_async(self, flag_key: str, default_value: float, + evaluation_context: EvaluationContext = EvaluationContext()): + return await self._evaluate_treatment_async(flag_key, evaluation_context, default_value) + + async def resolve_object_details_async(self, flag_key: str, default_value: dict, + evaluation_context: EvaluationContext = EvaluationContext()): + return await self._evaluate_treatment_async(flag_key, evaluation_context, default_value) + + async def _evaluate_treatment_async(self, key: str, evaluation_context: EvaluationContext, default_value): if evaluation_context is None: raise GeneralError("Evaluation Context must be provided for the Split Provider") + if not await self._split_client_wrapper.is_sdk_ready_async(): + return SplitProvider.construct_flag_resolution(default_value, None, None, Reason.ERROR, + ErrorCode.PROVIDER_NOT_READY) + targeting_key = evaluation_context.targeting_key if not targeting_key: raise TargetingKeyMissingError("Missing targeting key") attributes = SplitProvider.transform_context(evaluation_context) - return self.split_client.get_treatment(targeting_key, key, attributes) - - @staticmethod - def transform_context(evaluation_context: EvaluationContext): - return evaluation_context.attributes - - @staticmethod - def no_treatment(treatment: str): - return not treatment or treatment == "control" - - @staticmethod - def construct_flag_resolution(value, variant: str, reason: Reason = Reason.TARGETING_MATCH, - error_code: ErrorCode = None): - return FlagResolutionDetails(value=value, error_code=error_code, reason=reason, variant=variant) + evaluated = await self._split_client_wrapper.split_client.get_treatment_with_config(targeting_key, key, attributes) + return self._process_treatment(evaluated, default_value) diff --git a/tests/split.yaml b/tests/split.yaml index 2c0597c..1bc7626 100644 --- a/tests/split.yaml +++ b/tests/split.yaml @@ -11,4 +11,4 @@ - float_feature: treatment: "50.5" - obj_feature: - treatment: "{\"key\": \"value\"}" + treatment: "key" diff --git a/tests/test_client.py b/tests/test_client.py index 9c4e8b7..e64a508 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,6 @@ import pytest +from threading import Event + from openfeature import api from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode @@ -6,9 +8,7 @@ from splitio import get_factory from split_openfeature import SplitProvider - class TestClient(object): - # The following are splits with treatments defined in the split.yaml file my_feature = "my_feature" # 'on' when targeting_key='key', else 'off' some_other_feature = "some_other_feature" # 'off' @@ -21,10 +21,11 @@ def provider(self): split_factory = get_factory("localhost", config={"splitFile": "split.yaml"}) split_factory.block_until_ready(5) split_client = split_factory.client() - return SplitProvider(client=split_client) + return SplitProvider({"SplitClient": split_client}) @pytest.fixture def set_provider(self, provider): + self.provider = provider api.set_provider(provider) @pytest.fixture @@ -35,6 +36,10 @@ def client(self, set_provider): def targeting_key(self, client): client.context = EvaluationContext(targeting_key="key") + def _destroy_factory(self): + self.provider._split_client_wrapper._factory.destroy() + assert self.provider._split_client_wrapper._factory.destroyed + def test_use_default(self, client): # flags that do not exist should return the default value flag_name = "random-non-existent-feature" @@ -59,7 +64,7 @@ def test_use_default(self, client): default_obj = {"foo": "bar"} result = client.get_object_value(flag_name, default_obj) assert result == default_obj - + def test_missing_targeting_key(self, client): # Split requires a targeting key and should return the default treatment # and throw an error if not provided @@ -104,10 +109,6 @@ def test_float_split(self, client): result = client.get_float_value(self.float_feature, 2.3) assert result == 50.5 - def test_obj_split(self, client): - result = client.get_object_value(self.obj_feature, {}) - assert result == {"key": "value"} - def test_get_metadata(self): assert api.get_provider_metadata().name == "Split" @@ -145,12 +146,11 @@ def test_string_details(self, client): assert details.error_code is None def test_obj_details(self, client): - details = client.get_object_details(self.obj_feature, {}) + details = client.get_object_details(self.obj_feature, {"val": "control"}) assert details.flag_key == self.obj_feature - assert details.reason == Reason.TARGETING_MATCH - assert details.value == {"key": "value"} - assert details.variant == "{\"key\": \"value\"}" - assert details.error_code is None + assert details.reason == Reason.ERROR + assert details.error_code == ErrorCode.PARSE_ERROR + assert details.value == {"val": "control"} def test_boolean_fail(self, client): # attempt to fetch an object treatment as a Boolean. Should result in the default @@ -184,15 +184,9 @@ def test_float_fail(self, client): assert details.error_code == ErrorCode.PARSE_ERROR assert details.reason == Reason.ERROR assert details.variant is None - - def test_obj_fail(self, client): - # attempt to fetch a string treatment as an object. Should result in the default - default_treatment = {"foo": "bar"} - value = client.get_object_value(self.some_other_feature, default_treatment) - assert value == default_treatment - - details = client.get_object_details(self.some_other_feature, default_treatment) - assert details.value == default_treatment - assert details.error_code == ErrorCode.PARSE_ERROR - assert details.reason == Reason.ERROR - assert details.variant is None + self._destroy_factory() + +class TestClientInternal(TestClient): + @pytest.fixture + def provider(self): + return SplitProvider({"SdkKey": "localhost", "ConfigOptions": {"splitFile": "split.yaml"}}) diff --git a/tests/test_split_client_wrapper.py b/tests/test_split_client_wrapper.py new file mode 100644 index 0000000..391ff21 --- /dev/null +++ b/tests/test_split_client_wrapper.py @@ -0,0 +1,81 @@ +import pytest +import unittest +from threading import Event + +from splitio import get_factory, get_factory_async +from split_openfeature import SplitClientWrapper + +class TestSplitClientWrapper(unittest.TestCase): + def test_using_external_splitclient(self): + split_factory = get_factory("localhost", config={"splitFile": "split.yaml"}) + split_factory.block_until_ready(5) + split_client = split_factory.client() + wrapper = SplitClientWrapper({"SplitClient": split_client}) + assert wrapper.split_client != None + assert wrapper.is_sdk_ready() + + destroy_event = Event() + wrapper.destroy(destroy_event) + destroy_event.wait() + assert split_factory.destroyed + + def test_using_internal_splitclient(self): + wrapper = SplitClientWrapper({"ReadyBlockTime": 1, "SdkKey": "localhost", "ConfigOptions": {"splitFile": "split.yaml"}}) + assert wrapper.split_client != None + assert wrapper.is_sdk_ready() + assert wrapper.sdk_ready == 1 + destroy_event = Event() + wrapper.destroy(destroy_event) + destroy_event.wait() + assert wrapper._factory.destroyed + + def test_sdk_not_ready(self): + wrapper = SplitClientWrapper({"ReadyBlockTime": 0.1, "SdkKey": "api", "ConfigOptions": {}}) + assert not wrapper.is_sdk_ready() + wrapper.destroy() + + def test_invalid_apikey(self): + with self.assertRaises(AttributeError) as context: + wrapper = SplitClientWrapper({"SdkKey": 123}) + + def test_invalid_config(self): + with self.assertRaises(AttributeError) as context: + wrapper = SplitClientWrapper({"SdkKey": "123", "ConfigOptions": "234"}) + + def test_no_params(self): + with self.assertRaises(AttributeError) as context: + wrapper = SplitClientWrapper({}) + + def test_reqwuired_params(self): + with self.assertRaises(AttributeError) as context: + wrapper = SplitClientWrapper({"ConfigOptions": {}}) + +class TestSplitClientWrapperAsync(object): + @pytest.mark.asyncio + async def test_using_external_splitclient_async(self): + split_factory = await get_factory_async("localhost", config={"splitFile": "split.yaml"}) + await split_factory.block_until_ready(5) + split_client = split_factory.client() + wrapper = SplitClientWrapper({"SplitClient": split_client, "ThreadingMode": "asyncio"}) + await wrapper.create() + assert wrapper.split_client != None + assert await wrapper.is_sdk_ready_async() + await wrapper.destroy_async() + assert split_factory.destroyed + + @pytest.mark.asyncio + async def test_using_internal_splitclient_async(self): + wrapper = SplitClientWrapper({"ReadyBlockTime": 1, "SdkKey": "localhost", "ConfigOptions": {"splitFile": "split.yaml"}, "ThreadingMode": "asyncio"}) + await wrapper.create() + assert wrapper.split_client != None + assert await wrapper.is_sdk_ready_async() + assert wrapper.sdk_ready == True + await wrapper.destroy_async() + assert wrapper._factory.destroyed + + @pytest.mark.asyncio + async def test_sdk_not_ready_async(self): + wrapper = SplitClientWrapper({"ReadyBlockTime": 0.1, "SdkKey": "api", "ConfigOptions": {}, "ThreadingMode": "asyncio"}) + await wrapper.create() + assert not await wrapper.is_sdk_ready_async() + await wrapper.destroy_async() diff --git a/tests/test_split_provider.py b/tests/test_split_provider.py index f4a3417..265b288 100644 --- a/tests/test_split_provider.py +++ b/tests/test_split_provider.py @@ -1,9 +1,9 @@ from pytest import fail +import pytest from mock import MagicMock from openfeature.exception import ErrorCode, OpenFeatureError from openfeature.evaluation_context import EvaluationContext -from split_openfeature import SplitProvider - +from split_openfeature import SplitProvider, SplitProviderAsync class TestProvider(object): eval_context = EvaluationContext("someKey") @@ -11,13 +11,12 @@ class TestProvider(object): def reset_client(self): self.client = MagicMock() - self.provider = SplitProvider(client=self.client) + self.provider = SplitProvider({"SplitClient": self.client}) def mock_client_return(self, val): - self.client.get_treatment.return_value = val + self.client.get_treatment_with_config.return_value = (val, "{'prop':'val'}") # *** Boolean eval tests *** - def test_boolean_none_empty(self): # if a treatment is None or empty it should return the default treatment self.reset_client() @@ -28,12 +27,6 @@ def test_boolean_none_empty(self): result = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) assert not result.value - self.mock_client_return("") - result = self.provider.resolve_boolean_details(self.flag_name, True, self.eval_context) - assert result.value - result = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) - assert not result.value - def test_boolean_control(self): # if a treatment is "control" it should return the default treatment self.reset_client() @@ -47,25 +40,37 @@ def test_boolean_true(self): # treatment of "true" should eval to True boolean self.reset_client() self.mock_client_return("true") - assert self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context).value + details = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) + assert details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "true" def test_boolean_on(self): # treatment of "on" should eval to True boolean self.reset_client() self.mock_client_return("on") - assert self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context).value + details = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) + assert details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "on" def test_boolean_false(self): # treatment of "true" should eval to True boolean self.reset_client() self.mock_client_return("false") - assert not self.provider.resolve_boolean_details(self.flag_name, True, self.eval_context).value + details = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) + assert not details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "false" def test_boolean_off(self): # treatment of "on" should eval to True boolean self.reset_client() self.mock_client_return("off") - assert not self.provider.resolve_boolean_details(self.flag_name, True, self.eval_context).value + details = self.provider.resolve_boolean_details(self.flag_name, False, self.eval_context) + assert not details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "off" def test_boolean_error(self): # any other random string other than on,off,true,false,control should throw an error @@ -80,7 +85,6 @@ def test_boolean_error(self): fail("Unexpected exception occurred") # *** String eval tests *** - def test_string_none_empty(self): # if a treatment is None or empty it should return the default treatment self.reset_client() @@ -109,9 +113,10 @@ def test_string_regular(self): self.mock_client_return(treatment) result = self.provider.resolve_string_details(self.flag_name, "someDefaultTreatment", self.eval_context) assert result.value == treatment + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "treatment" # *** Integer eval tests *** - def test_int_none_empty(self): # if a treatment is null empty it should return the default treatment self.reset_client() @@ -141,6 +146,8 @@ def test_int_regular(self): result = self.provider.resolve_integer_details(self.flag_name, 1, self.eval_context) assert result.value == 50 assert isinstance(result.value, int) + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "50" def test_int_error(self): # an un-parsable int treatment should throw an error @@ -164,7 +171,6 @@ def test_int_error(self): fail("Unexpected exception occurred") # *** Float eval tests *** - def test_float_none_empty(self): # if a treatment is null empty it should return the default treatment self.reset_client() @@ -192,6 +198,8 @@ def test_float_regular(self): self.mock_client_return("50.5") result = self.provider.resolve_float_details(self.flag_name, 1.5, self.eval_context) assert result.value == 50.5 + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "50.5" # it should also be able to handle regular ints self.mock_client_return("50") @@ -212,7 +220,6 @@ def test_float_error(self): fail("Unexpected exception occurred") # *** Object eval tests *** - def test_obj_none_empty(self): # if a treatment is null empty it should return the default treatment self.reset_client() @@ -240,6 +247,8 @@ def test_obj_regular(self): self.mock_client_return('{"foo": "bar"}') result = self.provider.resolve_object_details(self.flag_name, {"blah": "blaah"}, self.eval_context) assert result.value == {"foo": "bar"} + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == '{"foo": "bar"}' def test_obj_complex(self): self.reset_client() @@ -258,3 +267,313 @@ def test_obj_error(self): assert e.error_code == ErrorCode.PARSE_ERROR except Exception: fail("Unexpected exception occurred") + + def test_sdk_not_ready(self): + provider = SplitProvider({"ReadyBlockTime": 0.1,"SdkKey": "api"}) + details = provider.resolve_boolean_details(self.flag_name, False, self.eval_context) + assert details.error_code == ErrorCode.PROVIDER_NOT_READY + assert details.value == False + +class TestProviderAsync(object): + eval_context = EvaluationContext("someKey") + flag_name = "flagName" + + async def reset_client(self): + self.client = MagicMock() + self._factory = self.client._factory + + async def block_until_ready(x): + pass + self._factory.block_until_ready = block_until_ready + + self.provider = SplitProviderAsync({"SplitClient": self.client}) + await self.provider.create() + + def mock_client_return(self, val): + async def get_treatment_with_config(*_): + return (val, "{'prop':'val'}") + self.client.get_treatment_with_config = get_treatment_with_config + + # *** Boolean eval tests *** + @pytest.mark.asyncio + async def test_boolean_none_empty(self): + # if a treatment is None or empty it should return the default treatment + await self.reset_client() + + self.mock_client_return(None) + result = await self.provider.resolve_boolean_details_async(self.flag_name, True, self.eval_context) + assert result.value + result = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert not result.value + + @pytest.mark.asyncio + async def test_boolean_control(self): + # if a treatment is "control" it should return the default treatment + await self.reset_client() + self.mock_client_return("control") + result = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert not result.value + result = await self.provider.resolve_boolean_details_async(self.flag_name, True, self.eval_context) + assert result.value + + @pytest.mark.asyncio + async def test_boolean_true(self): + # treatment of "true" should eval to True boolean + await self.reset_client() + self.mock_client_return("true") + details = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "true" + + @pytest.mark.asyncio + async def test_boolean_on(self): + # treatment of "on" should eval to True boolean + await self.reset_client() + self.mock_client_return("on") + details = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "on" + + @pytest.mark.asyncio + async def test_boolean_false(self): + # treatment of "true" should eval to True boolean + await self.reset_client() + self.mock_client_return("false") + details = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert not details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "false" + + @pytest.mark.asyncio + async def test_boolean_off(self): + # treatment of "on" should eval to True boolean + await self.reset_client() + self.mock_client_return("off") + details = await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert not details.value + assert details.flag_metadata["config"] == "{'prop':'val'}" + assert details.variant == "off" + + @pytest.mark.asyncio + async def test_boolean_error(self): + # any other random string other than on,off,true,false,control should throw an error + await self.reset_client() + self.mock_client_return("a random string") + try: + await self.provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + fail("Should have thrown an error casting string to boolean") + except OpenFeatureError as e: + assert e.error_code == ErrorCode.PARSE_ERROR + except Exception: + fail("Unexpected exception occurred") + + # *** String eval tests *** + @pytest.mark.asyncio + async def test_string_none_empty(self): + # if a treatment is None or empty it should return the default treatment + await self.reset_client() + default_treatment = "defaultTreatment" + + self.mock_client_return(None) + result = await self.provider.resolve_string_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + self.mock_client_return("") + result = await self.provider.resolve_string_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_string_control(self): + # "control" treatment should eval to default treatment + await self.reset_client() + self.mock_client_return("control") + default_treatment = "defaultTreatment" + result = await self.provider.resolve_string_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_string_regular(self): + # a string treatment should eval to itself + await self.reset_client() + treatment = "treatment" + self.mock_client_return(treatment) + result = await self.provider.resolve_string_details_async(self.flag_name, "someDefaultTreatment", self.eval_context) + assert result.value == treatment + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "treatment" + + # *** Integer eval tests *** + @pytest.mark.asyncio + async def test_int_none_empty(self): + # if a treatment is null empty it should return the default treatment + await self.reset_client() + default_treatment = 10 + + self.mock_client_return(None) + result = await self.provider.resolve_integer_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + self.mock_client_return("") + result = await self.provider.resolve_integer_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_int_control(self): + # "control" treatment should eval to default treatment + await self.reset_client() + self.mock_client_return("control") + default_treatment = 10 + result = await self.provider.resolve_integer_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_int_regular(self): + # a parsable int string treatment should eval to that integer + await self.reset_client() + + self.mock_client_return("50") + result = await self.provider.resolve_integer_details_async(self.flag_name, 1, self.eval_context) + assert result.value == 50 + assert isinstance(result.value, int) + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "50" + + @pytest.mark.asyncio + async def test_int_error(self): + # an un-parsable int treatment should throw an error + await self.reset_client() + self.mock_client_return("notAnInt") + try: + await self.provider.resolve_integer_details_async(self.flag_name, 100, self.eval_context) + fail("Should have thrown an exception casting string to num") + except OpenFeatureError as e: + assert e.error_code == ErrorCode.PARSE_ERROR + except Exception: + fail("Unexpected exception occurred") + + self.mock_client_return("50.5") + try: + await self.provider.resolve_integer_details_async(self.flag_name, 100, self.eval_context) + fail("Should have thrown an exception casting string to int") + except OpenFeatureError as e: + assert e.error_code == ErrorCode.PARSE_ERROR + except Exception: + fail("Unexpected exception occurred") + + # *** Float eval tests *** + @pytest.mark.asyncio + async def test_float_none_empty(self): + # if a treatment is null empty it should return the default treatment + await self.reset_client() + default_treatment = 10.5 + + self.mock_client_return(None) + result = await self.provider.resolve_float_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + self.mock_client_return("") + result = await self.provider.resolve_float_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_float_control(self): + # "control" treatment should eval to default treatment + await self.reset_client() + self.mock_client_return("control") + default_treatment = 10.5 + result = await self.provider.resolve_float_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_float_regular(self): + # a parsable float string treatment should eval to that float + await self.reset_client() + self.mock_client_return("50.5") + result = await self.provider.resolve_float_details_async(self.flag_name, 1.5, self.eval_context) + assert result.value == 50.5 + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == "50.5" + + # it should also be able to handle regular ints + self.mock_client_return("50") + result = await self.provider.resolve_float_details_async(self.flag_name, 1.5, self.eval_context) + assert result.value == 50.0 + assert isinstance(result.value, float) + + @pytest.mark.asyncio + async def test_float_error(self): + # an un-parsable float treatment should throw an error + await self.reset_client() + self.mock_client_return("notAFloat") + try: + await self.provider.resolve_float_details_async(self.flag_name, 100.5, self.eval_context) + fail("Should have thrown an exception casting string to float") + except OpenFeatureError as e: + assert e.error_code == ErrorCode.PARSE_ERROR + except Exception: + fail("Unexpected exception occurred") + + # *** Object eval tests *** + @pytest.mark.asyncio + async def test_obj_none_empty(self): + # if a treatment is null empty it should return the default treatment + await self.reset_client() + default_treatment = {"foo": "bar"} + + self.mock_client_return(None) + result = await self.provider.resolve_object_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + self.mock_client_return("") + result = await self.provider.resolve_object_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_obj_control(self): + # "control" treatment should eval to default treatment + await self.reset_client() + self.mock_client_return("control") + default_treatment = {"foo": "bar"} + result = await self.provider.resolve_object_details_async(self.flag_name, default_treatment, self.eval_context) + assert result.value == default_treatment + + @pytest.mark.asyncio + async def test_obj_regular(self): + # an object treatment should eval to that object + await self.reset_client() + self.mock_client_return('{"foo": "bar"}') + result = await self.provider.resolve_object_details_async(self.flag_name, {"blah": "blaah"}, self.eval_context) + assert result.value == {"foo": "bar"} + assert result.flag_metadata["config"] == "{'prop':'val'}" + assert result.variant == '{"foo": "bar"}' + + @pytest.mark.asyncio + async def test_obj_complex(self): + await self.reset_client() + self.mock_client_return('{"string": "blah", "int": 10, "bool": true, "struct": {"foo": "bar"}, "list": [1, 2]}') + result = await self.provider.resolve_object_details_async(self.flag_name, {"blah": "blaah"}, self.eval_context) + assert result.value == {"string": "blah", "int": 10, "bool": True, "struct": {"foo": "bar"}, "list": [1, 2]} + + @pytest.mark.asyncio + async def test_obj_error(self): + # a treatment that can not be converted to an object should throw an error + await self.reset_client() + self.mock_client_return("not an object") + try: + await self.provider.resolve_object_details_async(self.flag_name, {"foo": "bar"}, self.eval_context) + fail("Should have thrown an exception casting string to an object") + except OpenFeatureError as e: + assert e.error_code == ErrorCode.PARSE_ERROR + except Exception: + fail("Unexpected exception occurred") + + @pytest.mark.asyncio + async def test_sdk_not_ready(self): + provider = SplitProviderAsync({"ReadyBlockTime": 0.1,"SdkKey": "api"}) + await provider.create() + details = await provider.resolve_boolean_details_async(self.flag_name, False, self.eval_context) + assert details.error_code == ErrorCode.PROVIDER_NOT_READY + assert details.value == False + await provider._split_client_wrapper._factory.destroy() \ No newline at end of file