diff --git a/.coveragerc b/.coveragerc index c6294d3..bb62515 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] source = - splitio/ + split_openfeature_provider/ omit = tests/* diff --git a/.github/workflows/CODEOWNERS b/.github/workflows/CODEOWNERS index ab53a7c..9e31981 100644 --- a/.github/workflows/CODEOWNERS +++ b/.github/workflows/CODEOWNERS @@ -1 +1 @@ -* @splitio/sdk \ No newline at end of file +* @splitio/sdk diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2598dd3..ae4966e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: ci + on: push: branches: @@ -16,58 +17,54 @@ concurrency: jobs: test: name: Test - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v6 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 + pip install -U setuptools pip wheel + pip install -e . + pip install -r requirements-dev.txt - - name: Run tests - run: cd tests; pytest -v + - name: Run tests with coverage + working-directory: tests + run: pytest -v --cov=split_openfeature_provider --cov-report=xml:../coverage.xml - 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 + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: projectBaseDir: . args: > - -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} + -Dsonar.host.url=${{ vars.SONARQUBE_HOST }} -Dsonar.projectVersion=${{ env.VERSION }} - name: SonarQube Scan (Pull Request) if: github.event_name == 'pull_request' - uses: SonarSource/sonarcloud-github-action@v1.9 + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: projectBaseDir: . args: > - -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} + -Dsonar.host.url=${{ vars.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 + -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} diff --git a/.gitignore b/.gitignore index 9ce3aa4..2a0741b 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,4 @@ dmypy.json .idea/ # Other -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..49239fa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-json + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/README.md b/README.md index 8ebae0c..32c717d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This SDK is compatible with Python 3.9 and higher. This package replaces the previous `split-openfeature-provider` Python provider in [Pypi](https://pypi.org/project/split-openfeature-provider/). -### Pip Installation +### Pip Installation ```python pip install split-openfeature-provider==1.0.0 ``` @@ -57,12 +57,12 @@ client = api.get_client("CLIENT_NAME") context = EvaluationContext(targeting_key="TARGETING_KEY") value = client.get_boolean_value("FLAG_NAME", False, context) ``` -If the same targeting key is used repeatedly, the evaluation context may be set at the client level +If the same targeting key is used repeatedly, the evaluation context may be set at the client level ```python context = EvaluationContext(targeting_key="TARGETING_KEY") client.context = context ``` -or at the OpenFeatureAPI level +or at the OpenFeatureAPI level ```python context = EvaluationContext(targeting_key="TARGETING_KEY") api.set_evaluation_context(context) @@ -137,7 +137,7 @@ 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. ## Contributing @@ -147,13 +147,13 @@ Please see [Contributors Guide](CONTRIBUTORS-GUIDE.md) to find all you need to s Licensed under the Apache License, Version 2.0. See: [Apache License](http://www.apache.org/licenses/). ## About Split - + Split is the leading Feature Delivery Platform for engineering teams that want to confidently deploy features as fast as they can develop them. Split’s fine-grained management, real-time monitoring, and data-driven experimentation ensure that new features will improve the customer experience without breaking or degrading performance. Companies like Twilio, Salesforce, GoDaddy and WePay trust Split to power their feature delivery. - + To learn more about Split, contact hello@split.io, or get started with feature flags for free at https://www.split.io/signup. - + Split has built and maintains SDKs for: - + * Java [Github](https://github.com/splitio/java-client) [Docs](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) * Javascript [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK) * Node [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK) @@ -164,10 +164,9 @@ Split has built and maintains SDKs for: * GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK) * Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK) * iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK) - + For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). - + **Learn more about Split:** - -Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](http://help.split.io) for more detailed information. +Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](http://help.split.io) for more detailed information. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..bc215d6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +mock>=5.0.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.0.0 +pytest>=7.0.0 diff --git a/requirements.txt b/requirements.txt index b4ddeda..42865f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ openfeature_sdk==0.8.3 -splitio_client[cpphash,asyncio]==10.5.1 \ No newline at end of file +splitio_client[cpphash,asyncio]==10.5.1 diff --git a/split_openfeature_provider/__init__.py b/split_openfeature_provider/__init__.py index e7b46b8..fca6966 100644 --- a/split_openfeature_provider/__init__.py +++ b/split_openfeature_provider/__init__.py @@ -1,3 +1,2 @@ from split_openfeature_provider.split_provider import SplitProvider, SplitProviderAsync from split_openfeature_provider.split_client_wrapper import SplitClientWrapper - diff --git a/split_openfeature_provider/split_client_wrapper.py b/split_openfeature_provider/split_client_wrapper.py index df740a5..048340a 100644 --- a/split_openfeature_provider/split_client_wrapper.py +++ b/split_openfeature_provider/split_client_wrapper.py @@ -5,19 +5,19 @@ _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() + 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") @@ -32,41 +32,41 @@ def __init__(self, initial_context): 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): @@ -74,24 +74,24 @@ def destroy(self, destroy_event=None): 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 @@ -103,5 +103,5 @@ def _validate_context(self, initial_context): 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 + + return True diff --git a/split_openfeature_provider/split_provider.py b/split_openfeature_provider/split_provider.py index 3bf54b1..e57bcbd 100644 --- a/split_openfeature_provider/split_provider.py +++ b/split_openfeature_provider/split_provider.py @@ -26,7 +26,7 @@ def _evaluate_treatment(self, key: str, evaluation_context: EvaluationContext, d 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") @@ -42,12 +42,12 @@ def _process_treatment(self, evaluated, default_value): 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) value = treatment - try: + try: if type(default_value) is int: value = int(treatment) elif isinstance(default_value, float): @@ -62,22 +62,22 @@ def _process_treatment(self, evaluated, default_value): 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") + raise ParseError("Could not convert treatment") except OpenFeatureError as ex: _LOGGER.error("Evaluation OpenFeature Exception") _LOGGER.debug(ex) raise - + except Exception as ex: _LOGGER.error("Evaluation Exception") _LOGGER.debug(ex) @@ -94,13 +94,13 @@ def no_treatment(treatment: str): @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, + 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()): pass @@ -127,7 +127,7 @@ async def resolve_string_details_async(self, flag_key: str, default_value: str, 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 @@ -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): @@ -168,7 +168,7 @@ def __init__(self, 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) @@ -196,7 +196,7 @@ async def _evaluate_treatment_async(self, key: str, evaluation_context: Evaluati 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") diff --git a/tests/test_client.py b/tests/test_client.py index ede9c76..05411d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -38,8 +38,8 @@ def targeting_key(self, client): def _destroy_factory(self): self.provider._split_client_wrapper._factory.destroy() - assert self.provider._split_client_wrapper._factory.destroyed - + 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" @@ -64,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 @@ -185,8 +185,8 @@ def test_float_fail(self, client): 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"}}) + return SplitProvider({"SdkKey": "localhost", "ConfigOptions": {"splitFile": "split.yaml"}}) diff --git a/tests/test_split_client_wrapper.py b/tests/test_split_client_wrapper.py index 4634535..9c1982f 100644 --- a/tests/test_split_client_wrapper.py +++ b/tests/test_split_client_wrapper.py @@ -13,7 +13,7 @@ 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() @@ -30,7 +30,7 @@ def test_using_internal_splitclient(self): assert wrapper._factory.destroyed def test_sdk_not_ready(self): - wrapper = SplitClientWrapper({"ReadyBlockTime": 0.1, "SdkKey": "api", "ConfigOptions": {}}) + wrapper = SplitClientWrapper({"ReadyBlockTime": 0.1, "SdkKey": "api", "ConfigOptions": {}}) assert not wrapper.is_sdk_ready() wrapper.destroy() @@ -75,7 +75,7 @@ async def test_using_internal_splitclient_async(self): @pytest.mark.asyncio async def test_sdk_not_ready_async(self): - wrapper = SplitClientWrapper({"ReadyBlockTime": 0.1, "SdkKey": "api", "ConfigOptions": {}, "ThreadingMode": "asyncio"}) + 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 dab03c1..0148fa9 100644 --- a/tests/test_split_provider.py +++ b/tests/test_split_provider.py @@ -267,7 +267,7 @@ 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) @@ -281,11 +281,11 @@ class TestProviderAsync(object): 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() @@ -568,7 +568,7 @@ async def test_obj_error(self): 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"}) @@ -576,4 +576,4 @@ async def test_sdk_not_ready(self): 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 + await provider._split_client_wrapper._factory.destroy()