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/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000..3f88f35 Binary files /dev/null and b/.github/.DS_Store differ 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..151b363 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,13 @@ 0.0.1 - First release. Up to date with spec 0.5.1 and python sdk 0.0.6 + 0.1.0 - Up to date with spec 0.8.0 and python sdk 0.8.1. Using split client 10.2.0 + +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 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 cce7b2a..b4ddeda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ openfeature_sdk==0.8.3 -splitio_client==10.5.1 +splitio_client[cpphash,asyncio]==10.5.1 \ No newline at end of file diff --git a/setup.py b/setup.py index dc64293..bd05f97 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,6 @@ setuptools.setup( name="split_openfeature", version="1.0.0", - author="Robert Grassian", - author_email="robert.grassian@split.io", 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/split_client_wrapper.py b/split_openfeature/split_client_wrapper.py index 7496d75..df740a5 100644 --- a/split_openfeature/split_client_wrapper.py +++ b/split_openfeature/split_client_wrapper.py @@ -69,6 +69,12 @@ def is_sdk_ready(self): 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 diff --git a/split_openfeature/split_provider.py b/split_openfeature/split_provider.py index e2adc1e..5c240b1 100644 --- a/split_openfeature/split_provider.py +++ b/split_openfeature/split_provider.py @@ -1,5 +1,6 @@ import typing import logging +import json from openfeature.hook import Hook from openfeature.evaluation_context import EvaluationContext @@ -7,7 +8,6 @@ from openfeature.flag_evaluation import Reason, FlagResolutionDetails from openfeature.provider import AbstractProvider, Metadata from split_openfeature.split_client_wrapper import SplitClientWrapper -import json _LOGGER = logging.getLogger(__name__) @@ -159,7 +159,7 @@ def resolve_float_details(self, flag_key: str, default_value: float, 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): diff --git a/tests/test_client.py b/tests/test_client.py index b8966a6..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 @@ -23,6 +25,7 @@ def provider(self): @pytest.fixture def set_provider(self, provider): + self.provider = provider api.set_provider(provider) @pytest.fixture @@ -33,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" @@ -57,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 @@ -102,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" @@ -141,15 +144,13 @@ def test_string_details(self, client): assert details.value == "off" assert details.variant == "off" 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 @@ -183,19 +184,7 @@ 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 diff --git a/tests/test_split_client_wrapper.py b/tests/test_split_client_wrapper.py index 561e685..391ff21 100644 --- a/tests/test_split_client_wrapper.py +++ b/tests/test_split_client_wrapper.py @@ -1,5 +1,6 @@ import pytest import unittest +from threading import Event from splitio import get_factory, get_factory_async from split_openfeature import SplitClientWrapper @@ -12,16 +13,26 @@ def test_using_external_splitclient(self): 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: @@ -49,6 +60,8 @@ async def test_using_external_splitclient_async(self): 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): @@ -57,9 +70,12 @@ async def test_using_internal_splitclient_async(self): 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 08a7115..265b288 100644 --- a/tests/test_split_provider.py +++ b/tests/test_split_provider.py @@ -575,4 +575,5 @@ async def test_sdk_not_ready(self): 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 \ No newline at end of file + assert details.value == False + await provider._split_client_wrapper._factory.destroy() \ No newline at end of file