From 993d73e9ac94e0ce313f147ebbca7f0e596b2f1d Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Tue, 26 Aug 2025 20:45:50 +0700 Subject: [PATCH 01/29] feat: init Unleash provider Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/LICENSE | 201 ++++++++++++++++++ .../openfeature-provider-unleash/README.md | 28 +++ .../pyproject.toml | 70 ++++++ .../contrib/provider/unleash/__init__.py | 77 +++++++ .../src/openfeature/py.typed | 0 .../src/scripts/scripts.py | 28 +++ .../tests/test_provider.py | 44 ++++ pyproject.toml | 2 + uv.lock | 28 +++ 9 files changed, 478 insertions(+) create mode 100644 providers/openfeature-provider-unleash/LICENSE create mode 100644 providers/openfeature-provider-unleash/README.md create mode 100644 providers/openfeature-provider-unleash/pyproject.toml create mode 100644 providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py create mode 100644 providers/openfeature-provider-unleash/src/openfeature/py.typed create mode 100644 providers/openfeature-provider-unleash/src/scripts/scripts.py create mode 100644 providers/openfeature-provider-unleash/tests/test_provider.py diff --git a/providers/openfeature-provider-unleash/LICENSE b/providers/openfeature-provider-unleash/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/providers/openfeature-provider-unleash/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md new file mode 100644 index 00000000..d356615f --- /dev/null +++ b/providers/openfeature-provider-unleash/README.md @@ -0,0 +1,28 @@ +# Unleash Provider for OpenFeature + +This provider is designed to use [Unleash](https://unleash.io/). + +## Installation + +``` +pip install openfeature-provider-unleash +``` + +## Configuration and Usage + +Instantiate a new UnleashProvider instance and configure the OpenFeature SDK to use it: + +```python +from openfeature import api +from openfeature.contrib.provider.unleash import UnleashProvider + +api.set_provider(UnleashProvider()) +``` + +### Configuration options + + + +## License + +Apache 2.0 - See [LICENSE](./LICENSE) for more information. diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml new file mode 100644 index 00000000..406d2498 --- /dev/null +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["uv_build~=0.8.0"] +build-backend = "uv_build" + +[project] +name = "openfeature-provider-unleash" +version = "0.1.0" +description = "OpenFeature provider for Unleash" +readme = "README.md" +authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [ + "openfeature-sdk>=0.8.2", +] +requires-python = ">=3.9" + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk-contrib" + + +[dependency-groups] +dev = [ + "coverage[toml]>=7.10.0,<8.0.0", + "mypy[faster-cache]>=1.17.0,<2.0.0", + "pytest>=8.4.0,<9.0.0", +] + +[tool.uv.build-backend] +module-name = "openfeature" +module-root = "src" +namespace = true +source-exclude = [ + # `openfeature-sdk` already includes the `py.typed` file, + # but it is necessary for local type checking + "openfeature/py.typed" +] + +[tool.coverage.run] +omit = [ + "tests/**", +] + +[tool.mypy] +mypy_path = "src" +files = "src" + +python_version = "3.9" # should be identical to the minimum supported version +namespace_packages = true +explicit_package_bases = true +local_partial_types = true +pretty = true + +strict = true +disallow_any_generics = false + +[project.scripts] +# workaround while UV doesn't support scripts directly in the pyproject.toml +# see: https://github.com/astral-sh/uv/issues/5903 +cov-report = "scripts.scripts:cov_report" +cov = "scripts.scripts:cov" +# don't name it `mypy` otherwise `uv` will override the actual binary +mypy-check = "scripts.scripts:mypy" +test = "scripts.scripts:test" +test-cov = "scripts.scripts:test_cov" diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py new file mode 100644 index 00000000..23a8743d --- /dev/null +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -0,0 +1,77 @@ +from typing import List, Optional, Union + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails +from openfeature.hook import Hook +from openfeature.provider import AbstractProvider, Metadata + +__all__ = ["UnleashProvider"] + + +class UnleashProvider(AbstractProvider): + def __init__(self): + """Initialize the Unleash provider.""" + pass + + def get_metadata(self) -> Metadata: + """Get provider metadata.""" + raise NotImplementedError("UnleashProvider.get_metadata() not implemented") + + def get_provider_hooks(self) -> List[Hook]: + """Get provider hooks.""" + return [] + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + """Resolve boolean flag details.""" + raise NotImplementedError( + "UnleashProvider.resolve_boolean_details() not implemented" + ) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + """Resolve string flag details.""" + raise NotImplementedError( + "UnleashProvider.resolve_string_details() not implemented" + ) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + """Resolve integer flag details.""" + raise NotImplementedError( + "UnleashProvider.resolve_integer_details() not implemented" + ) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + """Resolve float flag details.""" + raise NotImplementedError( + "UnleashProvider.resolve_float_details() not implemented" + ) + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[dict, list], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Union[dict, list]]: + """Resolve object flag details.""" + raise NotImplementedError( + "UnleashProvider.resolve_object_details() not implemented" + ) diff --git a/providers/openfeature-provider-unleash/src/openfeature/py.typed b/providers/openfeature-provider-unleash/src/openfeature/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/providers/openfeature-provider-unleash/src/scripts/scripts.py b/providers/openfeature-provider-unleash/src/scripts/scripts.py new file mode 100644 index 00000000..2787d652 --- /dev/null +++ b/providers/openfeature-provider-unleash/src/scripts/scripts.py @@ -0,0 +1,28 @@ +# ruff: noqa: S602, S607 +import subprocess + + +def test() -> None: + """Run pytest tests.""" + subprocess.run("pytest tests", shell=True, check=True) + + +def test_cov() -> None: + """Run tests with coverage.""" + subprocess.run("coverage run -m pytest tests", shell=True, check=True) + + +def cov_report() -> None: + """Generate coverage report.""" + subprocess.run("coverage xml", shell=True, check=True) + + +def cov() -> None: + """Run tests with coverage and generate report.""" + test_cov() + cov_report() + + +def mypy() -> None: + """Run mypy.""" + subprocess.run("mypy", shell=True, check=True) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py new file mode 100644 index 00000000..1e663c3f --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -0,0 +1,44 @@ +import pytest + +from openfeature.contrib.provider.unleash import UnleashProvider + + +def test_unleash_provider_import(): + """Test that UnleashProvider can be imported.""" + assert UnleashProvider is not None + + +def test_unleash_provider_instantiation(): + """Test that UnleashProvider can be instantiated.""" + provider = UnleashProvider() + assert provider is not None + + +def test_unleash_provider_methods_not_implemented(): + """Test that UnleashProvider methods raise NotImplementedError.""" + provider = UnleashProvider() + + with pytest.raises(NotImplementedError): + provider.get_metadata() + + with pytest.raises(NotImplementedError): + provider.resolve_boolean_details("test_flag", True) + + with pytest.raises(NotImplementedError): + provider.resolve_string_details("test_flag", "default") + + with pytest.raises(NotImplementedError): + provider.resolve_integer_details("test_flag", 1) + + with pytest.raises(NotImplementedError): + provider.resolve_float_details("test_flag", 1.0) + + with pytest.raises(NotImplementedError): + provider.resolve_object_details("test_flag", {"key": "value"}) + + +def test_unleash_provider_hooks(): + """Test that UnleashProvider returns empty hooks list.""" + provider = UnleashProvider() + hooks = provider.get_provider_hooks() + assert hooks == [] diff --git a/pyproject.toml b/pyproject.toml index d34962c8..5685707e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "openfeature-provider-flagd", "openfeature-provider-flipt", "openfeature-provider-ofrep", + "openfeature-provider-unleash", ] [dependency-groups] @@ -31,6 +32,7 @@ openfeature-provider-env-var = { workspace = true } openfeature-provider-flagd = { workspace = true } openfeature-provider-flipt = { workspace = true } openfeature-provider-ofrep = { workspace = true } +openfeature-provider-unleash = { workspace = true } [tool.uv.workspace] members = [ diff --git a/uv.lock b/uv.lock index 9791429c..701366cb 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "openfeature-provider-flagd", "openfeature-provider-flipt", "openfeature-provider-ofrep", + "openfeature-provider-unleash", "openfeature-python-contrib", ] @@ -964,6 +965,31 @@ dev = [ { name = "types-requests", specifier = ">=2.32.0,<3.0.0" }, ] +[[package]] +name = "openfeature-provider-unleash" +version = "0.1.0" +source = { editable = "providers/openfeature-provider-unleash" } +dependencies = [ + { name = "openfeature-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "mypy", extra = ["faster-cache"] }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "openfeature-sdk", specifier = ">=0.8.2" }] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, + { name = "mypy", extras = ["faster-cache"], specifier = ">=1.17.0,<2.0.0" }, + { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, +] + [[package]] name = "openfeature-python-contrib" version = "0.0.0" @@ -974,6 +1000,7 @@ dependencies = [ { name = "openfeature-provider-flagd" }, { name = "openfeature-provider-flipt" }, { name = "openfeature-provider-ofrep" }, + { name = "openfeature-provider-unleash" }, ] [package.dev-dependencies] @@ -990,6 +1017,7 @@ requires-dist = [ { name = "openfeature-provider-flagd", editable = "providers/openfeature-provider-flagd" }, { name = "openfeature-provider-flipt", editable = "providers/openfeature-provider-flipt" }, { name = "openfeature-provider-ofrep", editable = "providers/openfeature-provider-ofrep" }, + { name = "openfeature-provider-unleash", editable = "providers/openfeature-provider-unleash" }, ] [package.metadata.requires-dev] From 78c57718ab8a84de17db067c56bb6242f94f6ebf Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 09:06:57 +0700 Subject: [PATCH 02/29] feat(provider): implement Unleash get_metadata Signed-off-by: Kiki L Hakiem --- .../openfeature/contrib/provider/unleash/__init__.py | 10 +++++----- .../tests/test_provider.py | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 23a8743d..7ce0071c 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,7 +1,7 @@ -from typing import List, Optional, Union +from typing import List, Mapping, Optional, Sequence, Union from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagResolutionDetails +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata @@ -9,13 +9,13 @@ class UnleashProvider(AbstractProvider): - def __init__(self): + def __init__(self) -> None: """Initialize the Unleash provider.""" pass def get_metadata(self) -> Metadata: """Get provider metadata.""" - raise NotImplementedError("UnleashProvider.get_metadata() not implemented") + return Metadata(name="Unleash Provider") def get_provider_hooks(self) -> List[Hook]: """Get provider hooks.""" @@ -68,7 +68,7 @@ def resolve_float_details( def resolve_object_details( self, flag_key: str, - default_value: Union[dict, list], + default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[Union[dict, list]]: """Resolve object flag details.""" diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 1e663c3f..41962bdf 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -14,13 +14,17 @@ def test_unleash_provider_instantiation(): assert provider is not None +def test_unleash_provider_get_metadata(): + """Test that UnleashProvider returns correct metadata.""" + provider = UnleashProvider() + metadata = provider.get_metadata() + assert metadata.name == "Unleash Provider" + + def test_unleash_provider_methods_not_implemented(): """Test that UnleashProvider methods raise NotImplementedError.""" provider = UnleashProvider() - with pytest.raises(NotImplementedError): - provider.get_metadata() - with pytest.raises(NotImplementedError): provider.resolve_boolean_details("test_flag", True) From 5f93e23b811cc76e71a49ad93b948784d43810ec Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 09:39:16 +0700 Subject: [PATCH 03/29] feat(provider): implement Unleash resolve_boolean_details Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 41 ++++- .../pyproject.toml | 1 + .../contrib/provider/unleash/__init__.py | 58 ++++++- .../tests/conftest.py | 24 +++ .../tests/test_provider.py | 58 ++++++- uv.lock | 146 +++++++++++++++++- 6 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 providers/openfeature-provider-unleash/tests/conftest.py diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index d356615f..9cc4ffda 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -16,12 +16,49 @@ Instantiate a new UnleashProvider instance and configure the OpenFeature SDK to from openfeature import api from openfeature.contrib.provider.unleash import UnleashProvider -api.set_provider(UnleashProvider()) +# Initialize the provider with your Unleash configuration +provider = UnleashProvider( + url="https://your-unleash-instance.com", + app_name="my-python-app", + api_token="your-api-token" +) + +api.set_provider(provider) ``` ### Configuration options - +- `url`: The URL of your Unleash server +- `app_name`: The name of your application +- `api_token`: The API token for authentication + +### Example usage + +```python +from openfeature import api +from openfeature.contrib.provider.unleash import UnleashProvider + +# Set up the provider +provider = UnleashProvider( + url="https://unleash.example.com", + app_name="my-app", + api_token="my-token" +) +api.set_provider(provider) + +# Get a client and evaluate flags +client = api.get_client() + +# Resolve a boolean flag +flag_details = client.get_boolean_details("my-feature-flag", default_value=False) +if flag_details.value: + print("Feature is enabled!") +else: + print("Feature is disabled") + +# Clean up when done +provider.shutdown() +``` ## License diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index 406d2498..81849df0 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ keywords = [] dependencies = [ "openfeature-sdk>=0.8.2", + "UnleashClient>=6.3.0", ] requires-python = ">=3.9" diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 7ce0071c..c5f20c14 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,17 +1,33 @@ from typing import List, Mapping, Optional, Sequence, Union from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata +from openfeature.exception import ErrorCode +from UnleashClient import UnleashClient __all__ = ["UnleashProvider"] class UnleashProvider(AbstractProvider): - def __init__(self) -> None: - """Initialize the Unleash provider.""" - pass + def __init__( + self, + url: str, + app_name: str, + api_token: str, + ) -> None: + """Initialize the Unleash provider. + + Args: + url: The Unleash API URL + app_name: The application name + api_token: The API token for authentication + """ + self.client = UnleashClient( + url=url, app_name=app_name, custom_headers={"Authorization": api_token} + ) + self.client.initialize_client() def get_metadata(self) -> Metadata: """Get provider metadata.""" @@ -21,6 +37,11 @@ def get_provider_hooks(self) -> List[Hook]: """Get provider hooks.""" return [] + def shutdown(self) -> None: + """Shutdown the Unleash client.""" + if hasattr(self, "client"): + self.client.destroy() + def resolve_boolean_details( self, flag_key: str, @@ -28,9 +49,32 @@ def resolve_boolean_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: """Resolve boolean flag details.""" - raise NotImplementedError( - "UnleashProvider.resolve_boolean_details() not implemented" - ) + try: + # UnleashClient.is_enabled expects (feature_name, context=None, fallback_function=None) + # We use a fallback function to return our default value + def fallback_func() -> bool: + return default_value + + value = self.client.is_enabled(flag_key, fallback_function=fallback_func) + return FlagResolutionDetails( + value=value, + reason=( + Reason.TARGETING_MATCH if value != default_value else Reason.DEFAULT + ), + variant=None, + error_code=None, + error_message=None, + flag_metadata={}, + ) + except Exception as e: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + variant=None, + error_code=ErrorCode.GENERAL, + error_message=str(e), + flag_metadata={}, + ) def resolve_string_details( self, diff --git a/providers/openfeature-provider-unleash/tests/conftest.py b/providers/openfeature-provider-unleash/tests/conftest.py new file mode 100644 index 00000000..bb42cca8 --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest +from unittest.mock import Mock, patch + +from openfeature import api +from openfeature.contrib.provider.unleash import UnleashProvider + + +@pytest.fixture() +def unleash_provider_client(): + """Create Unleash provider with test client using mocked UnleashClient.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + api.set_provider(provider) + yield api.get_client() + provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 41962bdf..cc21a81c 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -1,6 +1,9 @@ import pytest +from unittest.mock import Mock, patch from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.flag_evaluation import Reason +from openfeature.exception import ErrorCode def test_unleash_provider_import(): @@ -10,23 +13,28 @@ def test_unleash_provider_import(): def test_unleash_provider_instantiation(): """Test that UnleashProvider can be instantiated.""" - provider = UnleashProvider() + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) assert provider is not None + provider.shutdown() def test_unleash_provider_get_metadata(): """Test that UnleashProvider returns correct metadata.""" - provider = UnleashProvider() + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) metadata = provider.get_metadata() assert metadata.name == "Unleash Provider" + provider.shutdown() def test_unleash_provider_methods_not_implemented(): """Test that UnleashProvider methods raise NotImplementedError.""" - provider = UnleashProvider() - - with pytest.raises(NotImplementedError): - provider.resolve_boolean_details("test_flag", True) + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) with pytest.raises(NotImplementedError): provider.resolve_string_details("test_flag", "default") @@ -43,6 +51,42 @@ def test_unleash_provider_methods_not_implemented(): def test_unleash_provider_hooks(): """Test that UnleashProvider returns empty hooks list.""" - provider = UnleashProvider() + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) hooks = provider.get_provider_hooks() assert hooks == [] + provider.shutdown() + + +def test_unleash_provider_resolve_boolean_details(unleash_provider_client): + """Test that UnleashProvider can resolve boolean flags.""" + client = unleash_provider_client + + flag = client.get_boolean_details(flag_key="test_flag", default_value=False) + assert flag is not None + assert flag.value is True + assert flag.reason == Reason.TARGETING_MATCH + + +def test_unleash_provider_resolve_boolean_details_error(): + """Test that UnleashProvider handles errors gracefully.""" + mock_client = Mock() + mock_client.is_enabled.side_effect = Exception("Connection error") + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_boolean_details("test_flag", True) + assert flag.value is True + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.GENERAL + assert flag.error_message == "Connection error" + + provider.shutdown() diff --git a/uv.lock b/uv.lock index 701366cb..7acecd83 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,18 @@ members = [ "openfeature-python-contrib", ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + [[package]] name = "asserts" version = "0.13.1" @@ -370,6 +382,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fcache" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/94/60d2b17996d5edf1a62d213ad37a9d2f25fd6864997dfd2b099fffc3deed/fcache-0.6.0.tar.gz", hash = "sha256:79949f0aafe8cedc5c9064631b3c157941a288e59f9991dd158c23e8e60b5422", size = 7368, upload-time = "2024-11-19T22:32:16.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0c/ababd9fed96d865463f95f897c48a8781bc831f9e08cdc8124b96964f92c/fcache-0.6.0-py3-none-any.whl", hash = "sha256:dbf0753bb7400ed80d703df9ffcfb438786698872ed92c50169f95cb0eac8306", size = 8163, upload-time = "2024-11-19T22:32:18.178Z" }, +] + [[package]] name = "gherkin-official" version = "29.0.0" @@ -971,6 +995,7 @@ version = "0.1.0" source = { editable = "providers/openfeature-provider-unleash" } dependencies = [ { name = "openfeature-sdk" }, + { name = "unleashclient" }, ] [package.dev-dependencies] @@ -981,7 +1006,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "openfeature-sdk", specifier = ">=0.8.2" }] +requires-dist = [ + { name = "openfeature-sdk", specifier = ">=0.8.2" }, + { name = "unleashclient", specifier = ">=6.3.0" }, +] [package.metadata.requires-dev] dev = [ @@ -1098,6 +1126,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "platformdirs" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/e3/aa14d6b2c379fbb005993514988d956f1b9fdccd9cbe78ec0dbe5fb79bf5/platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", size = 19914, upload-time = "2023-10-02T15:16:30.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/29/3ec311dc18804409ecf0d2b09caa976f3ae6215559306b5b530004e11156/platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e", size = 17579, upload-time = "2023-10-02T15:16:29.336Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1168,6 +1205,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/7d/1461076b0cc9a9e6fa8b51b9dea2677182ba8bc248d99d95ca321f2c666f/pytest_bdd-8.1.0-py3-none-any.whl", hash = "sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb", size = 49149, upload-time = "2024-12-05T21:45:56.184Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1445,6 +1494,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "unleashclient" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apscheduler" }, + { name = "fcache" }, + { name = "importlib-metadata" }, + { name = "mmh3" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "semver" }, + { name = "yggdrasil-engine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/61/aa80b94959ab342ac23f3c241eec5e6fd5c6ce444213c9eb561ac7bf3561/unleashclient-6.3.0.tar.gz", hash = "sha256:9fb26abd617b514a45e66136c3ae491acf53c6e3a3d8e095c7bf973c7071ae0e", size = 45575, upload-time = "2025-07-04T12:13:26.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/49/ccf96e58d1fec26a7def1a2a0feb4af9f08b14aecd9c8fc20b88f62bc977/unleashclient-6.3.0-py3-none-any.whl", hash = "sha256:f33ccd877fee5de71f28ab92fdc58d82933a434e6f327f96a432e29d25ce921e", size = 34183, upload-time = "2025-07-04T12:13:25.641Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -1533,6 +1622,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "yggdrasil-engine" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/de/192e7701eccd12deb46b1cc2368b25ecabfb886eb4417377a398d00316e8/yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a1ed0b4d8d1702b3e6a1f454f89be2fdac544ff1b7d979afa62224377ae0cf6e", size = 1635076, upload-time = "2025-08-19T08:46:00.039Z" }, + { url = "https://files.pythonhosted.org/packages/e0/28/c9ef3c97fbaac61752b600e5d7a681d28b369d4f6f62f24f51bce173afcb/yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:9ff016dad3f6972d79dc121625df4c5ea35e8f69519de3873a408daf0b65bd54", size = 1685269, upload-time = "2025-08-19T08:46:01.262Z" }, + { url = "https://files.pythonhosted.org/packages/25/db/9d51658d69fccd018d71f95bdd544dcda44e1149849e408edb87a61b1537/yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:de577c8abd064861423b55d245e16770927332b1d3a6ef2e94ccb4b5d759349b", size = 1891895, upload-time = "2025-08-19T08:46:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/12f918e2eb253bd03dd19581f7b4f604e569024d96ea41293d225bf35209/yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1dece26decdcf1fc061bc9a3771618d00bf9431466cfe2b952151f92814d169", size = 1869230, upload-time = "2025-08-19T08:46:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/8c/11/b7b98b1bf7e282d6e3c6348c65c3587ca1cf6a91cdf52b6244f4cf51bbc2/yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:938f1a88c801f4ccc6289dced4033cb05a6f7042f373581ce7d8a999da6b82d0", size = 1877287, upload-time = "2025-08-19T08:46:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/dc/26/e3dbecb4f52bc9bd5269a0444ffc9c530d21d04e79276255364299c72466/yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6386ec0c0fd0a9c196e54b9cd36b1edecb0233a618496210cb70f914eae593b9", size = 1859822, upload-time = "2025-08-19T08:46:06.924Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4e/625571864f99c51934907c2a78b0b1a2ba6ddef699b4804131657ebdefbe/yggdrasil_engine-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:345c6003f07480fcf6d7c5ba8522de2cbafa4521bbc42013ceaeef34c2395a74", size = 3075906, upload-time = "2025-08-19T08:46:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/01d311d87cf01f95de2ef0925c1d695c607a59928df995ae7417f51f6f37/yggdrasil_engine-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:52f648a83049c3ff29bc2d8f6064c745895e38d021051948fab55a041d0d1195", size = 1207677, upload-time = "2025-08-19T08:46:09.818Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5f/afbd0141ba65f9ee17b2a9a818b866942b12cd3309aad77bfb5c0b922c5d/yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:cecae8456c3b45e71a84e7e5c9467fb26343c27a86c7b9b53c080959f5bf4092", size = 1635076, upload-time = "2025-08-19T08:46:10.851Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b4/27709ba3484d8f4f2a55d445eb85aac35a12a651f67ead5052d6005b7133/yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8f1ff08afa2a2c6b1b856439078a473abca275223636630fb7a67108e587fe79", size = 1685269, upload-time = "2025-08-19T08:46:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6c/69484d46b3760475b3aa1790270a29d6a2519433d85c4ed788976f4d886e/yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_aarch64.whl", hash = "sha256:447f2beb4c743db01aa8ae26eb38b5cd314aaa664388361006456ca6735b5a42", size = 1891895, upload-time = "2025-08-19T08:46:13.408Z" }, + { url = "https://files.pythonhosted.org/packages/18/43/3dfdf3ff2a0edd38ca019c2dea8ce2822d2e86b460563aac294ad971db52/yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:9c784fe3d47e88f4f059d59d47ea0e64c9e352d1e5e8b91efdaba36c3509d94d", size = 1869230, upload-time = "2025-08-19T08:46:14.772Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/1481108abccd1b5fc30faf6b32bf7a7a3da0ab5e304271a5f443f5a57073/yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cf325a422b1fb1033999b6dffac3ed211e1dabe4c7e5815ce3ce3d46819eff8b", size = 1877287, upload-time = "2025-08-19T08:46:16.224Z" }, + { url = "https://files.pythonhosted.org/packages/94/1c/f5a3501e507427b98c520f40b199f62f9aa73c7427e5538fdab02f0f0b06/yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f52b362ec2c5f9e192de99877b8e801b82a46ffe2ae565d7b676c549b1b88ad1", size = 1859822, upload-time = "2025-08-19T08:46:17.343Z" }, + { url = "https://files.pythonhosted.org/packages/de/2b/42b205638470b3161bd7b2abb07192bb5670981c119bbaffc6ec3f439c68/yggdrasil_engine-1.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e9adb857705bf646541124527b049c4cca17b82de9c0390ab51c8f7ab97474e1", size = 3075906, upload-time = "2025-08-19T08:46:18.443Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/0c264c12798cdeea52b25730b6d0448aedce44d033114755768ccfa25545/yggdrasil_engine-1.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:33a11e16f9436425896faf6cdff58eab43b0cd2979011c7aff63e3e919c8888a", size = 1207677, upload-time = "2025-08-19T08:46:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/c94607837a5619f2a99aa8e62f4242736b7365ed7210c2ac319494655952/yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:b92167283a0c4b04f38f738a04a990f01176a075f6a8af3e62d7174f34646fd1", size = 1635076, upload-time = "2025-08-19T08:46:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4a/d53fafc547369a8099fde0761ecffbe4ae1e9a7fba178b3035b4e4d99f99/yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:5d190184f3feace6112a220d47052398432cf3aa6eb33efe06dcfcb9b06a3d6d", size = 1685269, upload-time = "2025-08-19T08:46:22.098Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/779f9b4e81e23bea57f8de033a93680f2e8c13e91efcfafc8aaf6b1ee3df/yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_aarch64.whl", hash = "sha256:90d627ca958eefebeb8d183b7f53cf32f1bc0e558e600ce3334ecd56bbe2b1ef", size = 1891895, upload-time = "2025-08-19T08:46:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/3e/55/3377be5e2333f6c66b507107815fa1dbf0a5c21d31cd531965d13e3b8d5b/yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_x86_64.whl", hash = "sha256:0a86e2cd08225bd76e7df64f1a41582161b1ac19317f41f68175b6b79bc281ae", size = 1869230, upload-time = "2025-08-19T08:46:24.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/78/78e6790466f786b87912c2dbbed28bf7b00c031b4bd7a254cc8a25cdd2cb/yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a50704d10ed1054047eb87bca85c348f4c9cba35d6bff9058c2c487739c3aa3c", size = 1877287, upload-time = "2025-08-19T08:46:26.13Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e4/ab7f037ce259da5aec651db18de22982a295526598930425b7f4765f0ce5/yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:770509f97e27228dca593663f16d07cbd08f61535fe2f3203b725ff144e191ed", size = 1859822, upload-time = "2025-08-19T08:46:27.247Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/35b3e9f8ac971b73b555ba2e66b818ea327a6dd0b3da33ec1d3d1d40a06b/yggdrasil_engine-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:8438548e05525eaf4f770918a836e04829fdaee32e14da6f2ff7e8b3f2512d53", size = 3075906, upload-time = "2025-08-19T08:46:28.392Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ab/999c255b2d28b1cd26c2a07422f64f04d956365cd01242ce479cff6d28f9/yggdrasil_engine-1.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:69c0ec4ab606fad5dd8ff96b565af4c3a9c5dd1fd6844e549ea0bf3db4a2f763", size = 1207677, upload-time = "2025-08-19T08:46:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5e/89236ace268c6947c3dfefe5472f05903041f0f4b952abf635344c4755de/yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:90f9336a24a9e09fe211eb6079e5c4a9e2cc245e90842356be511bd7bc73c594", size = 1635076, upload-time = "2025-08-19T08:46:30.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/6cee729d46c066cf7e05a5212737819e856df27712ff365ba6e90b9195f5/yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:d0cabfd198b1ac927d0c56d0d667ab9497acb8b60351ff45a4000214c79a54a0", size = 1685269, upload-time = "2025-08-19T08:46:32.476Z" }, + { url = "https://files.pythonhosted.org/packages/ac/32/014e40a4d59084d53ef3e646b2de22515f6725475447e562f014bcb4202d/yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_aarch64.whl", hash = "sha256:21340f75a9283bfc1feb78ba071703f432e77ca1fcd32fe1a28a5d1314a8a39d", size = 1891895, upload-time = "2025-08-19T08:46:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b6/d28ae545685159c1747a420e6c628b4566b8f2162448e7d36ee3da38958c/yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_x86_64.whl", hash = "sha256:2cac50c85887a6c078ca6d3792d2b7241539d6e385648006ca8255cd20fce431", size = 1869230, upload-time = "2025-08-19T08:46:34.773Z" }, + { url = "https://files.pythonhosted.org/packages/83/d1/29bf203751d881edb4b7ced9835fc42e1453634ae167aaa417a76572bbb3/yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eb8bc69111e1f3d10649504a33b6a6677cd932037c6c7e77f12c80c1d8b3c63", size = 1877287, upload-time = "2025-08-19T08:46:35.817Z" }, + { url = "https://files.pythonhosted.org/packages/81/e7/b250b7b55fccaaf18a52735ab6543e94dbc4070577546b76d992d407456d/yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fe9d2ef59ebdbfbbc689b440c45283f3c385b7b5e1ee637618d8e279fc41301", size = 1859822, upload-time = "2025-08-19T08:46:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/3ea56000369cccf84e3d62bd6734aece0ddfcda25765de6eb818626b7097/yggdrasil_engine-1.0.0-cp313-abi3-win_amd64.whl", hash = "sha256:40131927c89f8f8bfc66e01f4b8e9168433b64c23b8a3ea1614bdddc0ca3ae14", size = 3075906, upload-time = "2025-08-19T08:46:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d8/ae8ae96933c02570dff48c1ed5727b8a1134e1a336452730e16b53cd3a22/yggdrasil_engine-1.0.0-cp313-abi3-win_arm64.whl", hash = "sha256:d5830f797a2309a99fdf49fe469a52ad5bb0bbb22ee68b409d47a07c72e5f5b3", size = 1207677, upload-time = "2025-08-19T08:46:39.156Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/556284244c20096978e70481866386ae586a2e3f44e739ac61317944f777/yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:cafb7e5bb3cb4143b2fd83f99250bd23f8c4d9f9c60f1fd8783c1615a4bdfcea", size = 1635076, upload-time = "2025-08-19T08:46:40.163Z" }, + { url = "https://files.pythonhosted.org/packages/21/2c/c8bacc4adbdbff1b6f0232289cff3d751c232382833531f004584ed96be3/yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:7aafb572c6d7ddbdd9724ad494f53add883bdd1def24a63e175854d5c76dd26e", size = 1685269, upload-time = "2025-08-19T08:46:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4e/91a4515659d752ab3bfbe4982c502c29dd6a7da32eabfa09e9e510e3f898/yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:4d73c51519455b7758f551982ba6f5ede70990b51da9e9c070cef2afcd2f36ce", size = 1891895, upload-time = "2025-08-19T08:46:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/64/b6/f02848f814b46aa107aa4cdc79d5907dd54d76df2942b647e7465e9a5e67/yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:1581d90fb52497cbd7a0eee54d8f325139c5f76d90a3931dbd42d9d0c0209d83", size = 1869230, upload-time = "2025-08-19T08:46:43.667Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ba/91449de81890a20bbebbc42837c1faa649d3810dede42ebb33916f39950c/yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b6b7a28e81e09c2719241b41ec63ff0acda00c2f2b5a54e83d703101440e41c1", size = 1877287, upload-time = "2025-08-19T08:46:44.779Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e6/bc855c2638d8393d5dff2895dcf1bbbbb948945516328f24d62e805b01c9/yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ab486a8401e3e0358fa75ee183dc9096443df979602c156a70a2259884f5f856", size = 1859822, upload-time = "2025-08-19T08:46:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d3/87/40fbbb6feef144d9a39abbeb0d52f73dbd2e441c85f83aad933423d73eb0/yggdrasil_engine-1.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:816fcf92995ab9725968746ad20258bb1d87602d2d2ebf14bc0b56aa405ab03e", size = 3075906, upload-time = "2025-08-19T08:46:46.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/43/212d2a7934450266189b813f1d7ab4421d5f6443965f95dfb64887e7fbab/yggdrasil_engine-1.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:2a8f833cdd555c33747532792cf0cc6620dca9bb6a693ab1ff8dea375a2d9970", size = 1207677, upload-time = "2025-08-19T08:46:47.939Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/fb27af1ad782a2b391fc6fd1e666fd0de3f6fb8bf5932620a7806011eef5/yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4b1765db6b31cd1d7bef5821493834003df50121604907bce82e84d3dc0c80d9", size = 1635076, upload-time = "2025-08-19T08:46:49.023Z" }, + { url = "https://files.pythonhosted.org/packages/f4/16/f4ef17e5028fe1efffe4a677d4efe82a161041c57bbaa0155f3e03e7ab5e/yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ee9ebcf0b206ce8959147d8b7faeab1833c0587e2573f8f3d6dd4a98605b8d97", size = 1685269, upload-time = "2025-08-19T08:46:50.082Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bc/883b102952bb961176ad037150f5e20bb390439c1fbb4589e66b4d9308e3/yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:42a199714bab52c9e22108a74b1b4cfdefd5f451139024cc6d8828111835189d", size = 1891895, upload-time = "2025-08-19T08:46:51.15Z" }, + { url = "https://files.pythonhosted.org/packages/64/ff/a2d1a283518bc5ff58ebbf30d316271f5ba171994fc753a14f8b35fbe64d/yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:f3132700301f2a7f0cb3509fda9d159db2fb28437ae1e1bd214604520ad10905", size = 1869230, upload-time = "2025-08-19T08:46:52.285Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/cc8329cf26d30e72e374129a013c14543e0722d24407b755c13d86b76a53/yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25503d8711ef3d009707be73eaaa500646b90ecd88085b4c2cbe6f2a176c3986", size = 1877287, upload-time = "2025-08-19T08:46:53.347Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/39f40e696e48eb218dd85797ae6a8907221d6043fe7193a633f03f218142/yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6d4ebaa62898e2e59da960ab6e24214d2b06312f36e5199fb9d22715b46224db", size = 1859822, upload-time = "2025-08-19T08:46:54.397Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5e/99adce5940be2b383f17f99084e59124e2a120e6382bb1ddd91f1de64510/yggdrasil_engine-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:5eb80a90d002d3f32e7ecf0743699818c84b39bfd349be20ab780177dc113c33", size = 3075906, upload-time = "2025-08-19T08:46:55.5Z" }, + { url = "https://files.pythonhosted.org/packages/56/7d/c1c8a142ddd1cd8ace71ac23043865fb3a4fa2440dfaf2b48107ce9b47fa/yggdrasil_engine-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:8be8bdd4e2ba82febe493c70fa5d4c1a6235c8f250f62db853ec3f4bcbdb2e63", size = 1207677, upload-time = "2025-08-19T08:46:57.954Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From e8936aa80613e3220f0d1eb10ac675db32dc7ff8 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 09:52:32 +0700 Subject: [PATCH 04/29] feat(provider): implement Unleash resolve_string_details, resolve_integer_details, resolve_float_details and resolve_object_details Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 21 ++- .../contrib/provider/unleash/__init__.py | 105 +++++++++++++-- .../tests/test_provider.py | 124 ++++++++++++++++-- 3 files changed, 221 insertions(+), 29 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 9cc4ffda..dcb36f29 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -49,12 +49,21 @@ api.set_provider(provider) # Get a client and evaluate flags client = api.get_client() -# Resolve a boolean flag -flag_details = client.get_boolean_details("my-feature-flag", default_value=False) -if flag_details.value: - print("Feature is enabled!") -else: - print("Feature is disabled") +# Resolve different types of flags +boolean_flag = client.get_boolean_details("my-boolean-flag", default_value=False) +string_flag = client.get_string_details("my-string-flag", default_value="default") +integer_flag = client.get_integer_details("my-integer-flag", default_value=0) +float_flag = client.get_float_details("my-float-flag", default_value=0.0) +object_flag = client.get_object_details("my-object-flag", default_value={"key": "value"}) + +# Check flag values +if boolean_flag.value: + print("Boolean feature is enabled!") + +print(f"String value: {string_flag.value}") +print(f"Integer value: {integer_flag.value}") +print(f"Float value: {float_flag.value}") +print(f"Object value: {object_flag.value}") # Clean up when done provider.shutdown() diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index c5f20c14..e7db78fb 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,4 +1,5 @@ -from typing import List, Mapping, Optional, Sequence, Union +import json +from typing import Any, Callable, List, Mapping, Optional, Sequence, Union from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason @@ -42,6 +43,74 @@ def shutdown(self) -> None: if hasattr(self, "client"): self.client.destroy() + def _resolve_variant_flag( + self, + flag_key: str, + default_value: Any, + value_converter: Callable[[Any], Any], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Any]: + """Helper method to resolve variant-based flags. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + value_converter: Function to convert payload value to desired type + evaluation_context: Optional evaluation context (ignored for now) + + Returns: + FlagResolutionDetails with the resolved value + """ + try: + # Use get_variant to get the variant payload + variant = self.client.get_variant(flag_key) + + # Check if the feature is enabled and has a payload + if variant.get("enabled", False) and "payload" in variant: + try: + payload_value = variant["payload"].get("value", default_value) + value = value_converter(payload_value) + return FlagResolutionDetails( + value=value, + reason=( + Reason.TARGETING_MATCH + if value != default_value + else Reason.DEFAULT + ), + variant=variant.get("name"), + error_code=None, + error_message=None, + flag_metadata={}, + ) + except (ValueError, TypeError): + # If payload value can't be converted, return default + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + variant=variant.get("name"), + error_code=None, + error_message=None, + flag_metadata={}, + ) + else: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + variant=None, + error_code=None, + error_message=None, + flag_metadata={}, + ) + except Exception as e: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + variant=None, + error_code=ErrorCode.GENERAL, + error_message=str(e), + flag_metadata={}, + ) + def resolve_boolean_details( self, flag_key: str, @@ -83,8 +152,8 @@ def resolve_string_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: """Resolve string flag details.""" - raise NotImplementedError( - "UnleashProvider.resolve_string_details() not implemented" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: payload_value ) def resolve_integer_details( @@ -94,8 +163,8 @@ def resolve_integer_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: """Resolve integer flag details.""" - raise NotImplementedError( - "UnleashProvider.resolve_integer_details() not implemented" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: int(payload_value) ) def resolve_float_details( @@ -105,8 +174,8 @@ def resolve_float_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: """Resolve float flag details.""" - raise NotImplementedError( - "UnleashProvider.resolve_float_details() not implemented" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: float(payload_value) ) def resolve_object_details( @@ -114,8 +183,22 @@ def resolve_object_details( flag_key: str, default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[Union[dict, list]]: + ) -> FlagResolutionDetails[ + Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] + ]: """Resolve object flag details.""" - raise NotImplementedError( - "UnleashProvider.resolve_object_details() not implemented" - ) + + def object_converter(payload_value: Any) -> Union[dict, list]: + # If payload is a string, try to parse it as JSON + if isinstance(payload_value, str): + value = json.loads(payload_value) + else: + value = payload_value + + # Ensure the value is a valid object (dict or list) + if isinstance(value, (dict, list)): + return value + else: + raise ValueError("Payload value is not a valid object") + + return self._resolve_variant_flag(flag_key, default_value, object_converter) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index cc21a81c..966b2ac5 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -30,23 +30,19 @@ def test_unleash_provider_get_metadata(): provider.shutdown() -def test_unleash_provider_methods_not_implemented(): - """Test that UnleashProvider methods raise NotImplementedError.""" +def test_unleash_provider_all_methods_implemented(): + """Test that all UnleashProvider methods are implemented.""" provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - with pytest.raises(NotImplementedError): - provider.resolve_string_details("test_flag", "default") + # All methods should be callable (not raise NotImplementedError) + assert callable(provider.resolve_string_details) + assert callable(provider.resolve_integer_details) + assert callable(provider.resolve_float_details) + assert callable(provider.resolve_object_details) - with pytest.raises(NotImplementedError): - provider.resolve_integer_details("test_flag", 1) - - with pytest.raises(NotImplementedError): - provider.resolve_float_details("test_flag", 1.0) - - with pytest.raises(NotImplementedError): - provider.resolve_object_details("test_flag", {"key": "value"}) + provider.shutdown() def test_unleash_provider_hooks(): @@ -90,3 +86,107 @@ def test_unleash_provider_resolve_boolean_details_error(): assert flag.error_message == "Connection error" provider.shutdown() + + +def test_unleash_provider_resolve_string_details(): + """Test that UnleashProvider can resolve string flags.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "test-string"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_string_details("test_flag", "default") + assert flag.value == "test-string" + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +def test_unleash_provider_resolve_integer_details(): + """Test that UnleashProvider can resolve integer flags.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "42"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_integer_details("test_flag", 0) + assert flag.value == 42 + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +def test_unleash_provider_resolve_float_details(): + """Test that UnleashProvider can resolve float flags.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "3.14"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_float_details("test_flag", 0.0) + assert flag.value == 3.14 + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +def test_unleash_provider_resolve_object_details(): + """Test that UnleashProvider can resolve object flags.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": '{"key": "value", "number": 42}'}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_object_details("test_flag", {"default": "value"}) + assert flag.value == {"key": "value", "number": 42} + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() From a5071942d7fe15cc38f5937fd816f73e3c90ac7d Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 10:09:32 +0700 Subject: [PATCH 05/29] feat(provider): implement Unleash async resolve() functions Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 9 +- .../pyproject.toml | 1 + .../contrib/provider/unleash/__init__.py | 89 ++++++++++++ .../tests/test_provider.py | 132 ++++++++++++++++++ uv.lock | 25 ++++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index dcb36f29..30f5f269 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -49,13 +49,20 @@ api.set_provider(provider) # Get a client and evaluate flags client = api.get_client() -# Resolve different types of flags +# Resolve different types of flags (synchronous) boolean_flag = client.get_boolean_details("my-boolean-flag", default_value=False) string_flag = client.get_string_details("my-string-flag", default_value="default") integer_flag = client.get_integer_details("my-integer-flag", default_value=0) float_flag = client.get_float_details("my-float-flag", default_value=0.0) object_flag = client.get_object_details("my-object-flag", default_value={"key": "value"}) +# Resolve different types of flags (asynchronous) +boolean_flag_async = await client.get_boolean_details_async("my-boolean-flag", default_value=False) +string_flag_async = await client.get_string_details_async("my-string-flag", default_value="default") +integer_flag_async = await client.get_integer_details_async("my-integer-flag", default_value=0) +float_flag_async = await client.get_float_details_async("my-float-flag", default_value=0.0) +object_flag_async = await client.get_object_details_async("my-object-flag", default_value={"key": "value"}) + # Check flag values if boolean_flag.value: print("Boolean feature is enabled!") diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index 81849df0..db18db9e 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -30,6 +30,7 @@ dev = [ "coverage[toml]>=7.10.0,<8.0.0", "mypy[faster-cache]>=1.17.0,<2.0.0", "pytest>=8.4.0,<9.0.0", + "pytest-asyncio>=0.23.0", ] [tool.uv.build-backend] diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index e7db78fb..d37f18f8 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -202,3 +202,92 @@ def object_converter(payload_value: Any) -> Union[dict, list]: raise ValueError("Payload value is not a valid object") return self._resolve_variant_flag(flag_key, default_value, object_converter) + + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + """Resolve boolean flag details asynchronously.""" + try: + + def fallback_func() -> bool: + return default_value + + value = self.client.is_enabled(flag_key, fallback_function=fallback_func) + return FlagResolutionDetails( + value=value, + reason=( + Reason.TARGETING_MATCH if value != default_value else Reason.DEFAULT + ), + variant=None, + error_code=None, + error_message=None, + flag_metadata={}, + ) + except Exception as e: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + variant=None, + error_code=ErrorCode.GENERAL, + error_message=str(e), + flag_metadata={}, + ) + + async def resolve_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + """Resolve string flag details asynchronously.""" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: payload_value + ) + + async def resolve_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + """Resolve integer flag details asynchronously.""" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: int(payload_value) + ) + + async def resolve_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + """Resolve float flag details asynchronously.""" + return self._resolve_variant_flag( + flag_key, default_value, lambda payload_value: float(payload_value) + ) + + async def resolve_object_details_async( + self, + flag_key: str, + default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[ + Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] + ]: + """Resolve object flag details asynchronously.""" + + def object_converter(payload_value: Any) -> Union[dict, list]: + if isinstance(payload_value, str): + value = json.loads(payload_value) + else: + value = payload_value + + if isinstance(value, (dict, list)): + return value + else: + raise ValueError("Payload value is not a valid object") + + return self._resolve_variant_flag(flag_key, default_value, object_converter) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 966b2ac5..6d73f511 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -190,3 +190,135 @@ def test_unleash_provider_resolve_object_details(): assert flag.variant == "test-variant" provider.shutdown() + + +@pytest.mark.asyncio +async def test_unleash_provider_resolve_boolean_details_async(): + """Test that UnleashProvider can resolve boolean flags asynchronously.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = await provider.resolve_boolean_details_async("test_flag", False) + assert flag.value is True + assert flag.reason == Reason.TARGETING_MATCH + + provider.shutdown() + + +@pytest.mark.asyncio +async def test_unleash_provider_resolve_string_details_async(): + """Test that UnleashProvider can resolve string flags asynchronously.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "test-string"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = await provider.resolve_string_details_async("test_flag", "default") + assert flag.value == "test-string" + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +@pytest.mark.asyncio +async def test_unleash_provider_resolve_integer_details_async(): + """Test that UnleashProvider can resolve integer flags asynchronously.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "42"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = await provider.resolve_integer_details_async("test_flag", 0) + assert flag.value == 42 + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +@pytest.mark.asyncio +async def test_unleash_provider_resolve_float_details_async(): + """Test that UnleashProvider can resolve float flags asynchronously.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "3.14"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = await provider.resolve_float_details_async("test_flag", 0.0) + assert flag.value == 3.14 + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +@pytest.mark.asyncio +async def test_unleash_provider_resolve_object_details_async(): + """Test that UnleashProvider can resolve object flags asynchronously.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": '{"key": "value", "number": 42}'}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = await provider.resolve_object_details_async( + "test_flag", {"default": "value"} + ) + assert flag.value == {"key": "value", "number": 42} + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() diff --git a/uv.lock b/uv.lock index 7acecd83..9bfc2ac0 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ec/1dd038c82f1dbe28daa0c9e7cffdf85bdc5b8161d23896f62da87754e834/asserts-0.13.1-py3-none-any.whl", hash = "sha256:f76aa6cc9950326801a4c0ecc2fcc57af2e604effe8fb7136d2802aec0b8f7b3", size = 12801, upload-time = "2024-04-29T18:50:10.495Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "cachebox" version = "5.0.2" @@ -1003,6 +1012,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "mypy", extra = ["faster-cache"] }, { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -1016,6 +1026,7 @@ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, { name = "mypy", extras = ["faster-cache"], specifier = ">=1.17.0,<2.0.0" }, { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, ] [[package]] @@ -1187,6 +1198,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + [[package]] name = "pytest-bdd" version = "8.1.0" From da35708badc52093e6eba92961b10f25537a9777 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 10:20:19 +0700 Subject: [PATCH 06/29] feat(provider): handle Unleash evaluation_context Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 16 ++ .../contrib/provider/unleash/__init__.py | 67 +++++++-- .../tests/test_provider.py | 137 ++++++++++++++++++ 3 files changed, 205 insertions(+), 15 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 30f5f269..2de97c43 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -63,6 +63,22 @@ integer_flag_async = await client.get_integer_details_async("my-integer-flag", d float_flag_async = await client.get_float_details_async("my-float-flag", default_value=0.0) object_flag_async = await client.get_object_details_async("my-object-flag", default_value={"key": "value"}) +# Using evaluation context for targeting +from openfeature.evaluation_context import EvaluationContext + +context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "country": "US", "plan": "premium"} +) + +# Resolve flags with context (synchronous) +boolean_with_context = client.get_boolean_details("my-boolean-flag", default_value=False, evaluation_context=context) +string_with_context = client.get_string_details("my-string-flag", default_value="default", evaluation_context=context) + +# Resolve flags with context (asynchronous) +boolean_async_with_context = await client.get_boolean_details_async("my-boolean-flag", default_value=False, evaluation_context=context) +string_async_with_context = await client.get_string_details_async("my-string-flag", default_value="default", evaluation_context=context) + # Check flag values if boolean_flag.value: print("Boolean feature is enabled!") diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index d37f18f8..c6b34e19 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -5,7 +5,14 @@ from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata -from openfeature.exception import ErrorCode +from openfeature.exception import ( + ErrorCode, + FlagNotFoundError, + GeneralError, + InvalidContextError, + ParseError, + TypeMismatchError, +) from UnleashClient import UnleashClient __all__ = ["UnleashProvider"] @@ -43,6 +50,19 @@ def shutdown(self) -> None: if hasattr(self, "client"): self.client.destroy() + def _build_unleash_context( + self, evaluation_context: Optional[EvaluationContext] = None + ) -> Optional[dict[str, Any]]: + """Convert OpenFeature evaluation context to Unleash context.""" + if not evaluation_context: + return None + + context: dict[str, Any] = {} + if evaluation_context.targeting_key: + context["userId"] = evaluation_context.targeting_key + context.update(evaluation_context.attributes) + return context + def _resolve_variant_flag( self, flag_key: str, @@ -63,7 +83,8 @@ def _resolve_variant_flag( """ try: # Use get_variant to get the variant payload - variant = self.client.get_variant(flag_key) + context = self._build_unleash_context(evaluation_context) + variant = self.client.get_variant(flag_key, context=context) # Check if the feature is enabled and has a payload if variant.get("enabled", False) and "payload" in variant: @@ -82,14 +103,14 @@ def _resolve_variant_flag( error_message=None, flag_metadata={}, ) - except (ValueError, TypeError): - # If payload value can't be converted, return default + except (ValueError, TypeError) as e: + # If payload value can't be converted, return error return FlagResolutionDetails( value=default_value, - reason=Reason.DEFAULT, + reason=Reason.ERROR, variant=variant.get("name"), - error_code=None, - error_message=None, + error_code=ErrorCode.TYPE_MISMATCH, + error_message=str(e), flag_metadata={}, ) else: @@ -119,12 +140,14 @@ def resolve_boolean_details( ) -> FlagResolutionDetails[bool]: """Resolve boolean flag details.""" try: - # UnleashClient.is_enabled expects (feature_name, context=None, fallback_function=None) - # We use a fallback function to return our default value + def fallback_func() -> bool: return default_value - value = self.client.is_enabled(flag_key, fallback_function=fallback_func) + context = self._build_unleash_context(evaluation_context) + value = self.client.is_enabled( + flag_key, context=context, fallback_function=fallback_func + ) return FlagResolutionDetails( value=value, reason=( @@ -215,7 +238,10 @@ async def resolve_boolean_details_async( def fallback_func() -> bool: return default_value - value = self.client.is_enabled(flag_key, fallback_function=fallback_func) + context = self._build_unleash_context(evaluation_context) + value = self.client.is_enabled( + flag_key, context=context, fallback_function=fallback_func + ) return FlagResolutionDetails( value=value, reason=( @@ -244,7 +270,10 @@ async def resolve_string_details_async( ) -> FlagResolutionDetails[str]: """Resolve string flag details asynchronously.""" return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: payload_value + flag_key, + default_value, + lambda payload_value: payload_value, + evaluation_context, ) async def resolve_integer_details_async( @@ -255,7 +284,10 @@ async def resolve_integer_details_async( ) -> FlagResolutionDetails[int]: """Resolve integer flag details asynchronously.""" return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: int(payload_value) + flag_key, + default_value, + lambda payload_value: int(payload_value), + evaluation_context, ) async def resolve_float_details_async( @@ -266,7 +298,10 @@ async def resolve_float_details_async( ) -> FlagResolutionDetails[float]: """Resolve float flag details asynchronously.""" return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: float(payload_value) + flag_key, + default_value, + lambda payload_value: float(payload_value), + evaluation_context, ) async def resolve_object_details_async( @@ -290,4 +325,6 @@ def object_converter(payload_value: Any) -> Union[dict, list]: else: raise ValueError("Payload value is not a valid object") - return self._resolve_variant_flag(flag_key, default_value, object_converter) + return self._resolve_variant_flag( + flag_key, default_value, object_converter, evaluation_context + ) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 6d73f511..19314e89 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import Reason from openfeature.exception import ErrorCode @@ -322,3 +323,139 @@ async def test_unleash_provider_resolve_object_details_async(): assert flag.variant == "test-variant" provider.shutdown() + + +def test_unleash_provider_with_evaluation_context(): + """Test that UnleashProvider uses evaluation context correctly.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "country": "US"}, + ) + + flag = provider.resolve_boolean_details("test_flag", False, context) + assert flag.value is True + + # Verify that context was passed to UnleashClient + mock_client.is_enabled.assert_called_with( + "test_flag", + context={"userId": "user123", "email": "user@example.com", "country": "US"}, + fallback_function=mock_client.is_enabled.call_args[1]["fallback_function"], + ) + + provider.shutdown() + + +def test_unleash_provider_type_mismatch_error(): + """Test that UnleashProvider handles type mismatch errors correctly.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "not-a-number"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_integer_details("test_flag", 0) + assert flag.value == 0 + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.TYPE_MISMATCH + assert "invalid literal for int()" in flag.error_message + + provider.shutdown() + + +def test_unleash_provider_parse_error(): + """Test that UnleashProvider handles parse errors correctly.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "invalid-json{"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_object_details("test_flag", {"default": "value"}) + assert flag.value == {"default": "value"} + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.TYPE_MISMATCH + assert "Expecting value" in flag.error_message + + provider.shutdown() + + +def test_unleash_provider_invalid_context_error(): + """Test that UnleashProvider handles invalid context errors correctly.""" + mock_client = Mock() + mock_client.is_enabled.side_effect = Exception("Invalid context provided") + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + context = EvaluationContext( + targeting_key="user123", attributes={"invalid": "context"} + ) + + flag = provider.resolve_boolean_details("test_flag", True, context) + assert flag.value is True + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.GENERAL + assert "Invalid context provided" in flag.error_message + + provider.shutdown() + + +def test_unleash_provider_flag_not_found_error(): + """Test that UnleashProvider handles flag not found errors correctly.""" + mock_client = Mock() + mock_client.get_variant.side_effect = Exception("Flag not found") + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + flag = provider.resolve_string_details("non_existent_flag", "default") + assert flag.value == "default" + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.GENERAL + assert "Flag not found" in flag.error_message + + provider.shutdown() From 4f539eda187b3edf7ea4fdda650b840b6da32169 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 10:54:32 +0700 Subject: [PATCH 07/29] feat(provider): improve Unleash error handling Signed-off-by: Kiki L Hakiem --- .../pyproject.toml | 1 + .../contrib/provider/unleash/__init__.py | 95 ++-- .../tests/test_provider.py | 451 +++++++++++------- uv.lock | 2 + 4 files changed, 338 insertions(+), 211 deletions(-) diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index db18db9e..e0b40bb7 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "mypy[faster-cache]>=1.17.0,<2.0.0", "pytest>=8.4.0,<9.0.0", "pytest-asyncio>=0.23.0", + "types-requests>=2.31.0", ] [tool.uv.build-backend] diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index c6b34e19..c1fded0e 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -6,13 +6,12 @@ from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata from openfeature.exception import ( - ErrorCode, FlagNotFoundError, GeneralError, - InvalidContextError, ParseError, TypeMismatchError, ) +import requests from UnleashClient import UnleashClient __all__ = ["UnleashProvider"] @@ -104,15 +103,11 @@ def _resolve_variant_flag( flag_metadata={}, ) except (ValueError, TypeError) as e: - # If payload value can't be converted, return error - return FlagResolutionDetails( - value=default_value, - reason=Reason.ERROR, - variant=variant.get("name"), - error_code=ErrorCode.TYPE_MISMATCH, - error_message=str(e), - flag_metadata={}, - ) + # If payload value can't be converted, raise TypeMismatchError + raise TypeMismatchError(str(e)) + except ParseError: + # Re-raise ParseError directly + raise else: return FlagResolutionDetails( value=default_value, @@ -122,15 +117,21 @@ def _resolve_variant_flag( error_message=None, flag_metadata={}, ) + except ( + FlagNotFoundError, + TypeMismatchError, + ParseError, + GeneralError, + ): + # Re-raise specific OpenFeature exceptions + raise + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + raise FlagNotFoundError(f"Flag not found: {e}") + else: + raise GeneralError(f"HTTP error: {e}") except Exception as e: - return FlagResolutionDetails( - value=default_value, - reason=Reason.ERROR, - variant=None, - error_code=ErrorCode.GENERAL, - error_message=str(e), - flag_metadata={}, - ) + raise GeneralError(f"Unexpected error: {e}") def resolve_boolean_details( self, @@ -158,15 +159,21 @@ def fallback_func() -> bool: error_message=None, flag_metadata={}, ) + except ( + FlagNotFoundError, + TypeMismatchError, + ParseError, + GeneralError, + ): + # Re-raise specific OpenFeature exceptions + raise + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + raise FlagNotFoundError(f"Flag not found: {e}") + else: + raise GeneralError(f"HTTP error: {e}") except Exception as e: - return FlagResolutionDetails( - value=default_value, - reason=Reason.ERROR, - variant=None, - error_code=ErrorCode.GENERAL, - error_message=str(e), - flag_metadata={}, - ) + raise GeneralError(f"Unexpected error: {e}") def resolve_string_details( self, @@ -212,13 +219,14 @@ def resolve_object_details( """Resolve object flag details.""" def object_converter(payload_value: Any) -> Union[dict, list]: - # If payload is a string, try to parse it as JSON if isinstance(payload_value, str): - value = json.loads(payload_value) + try: + value = json.loads(payload_value) + except json.JSONDecodeError as e: + raise ParseError(str(e)) else: value = payload_value - # Ensure the value is a valid object (dict or list) if isinstance(value, (dict, list)): return value else: @@ -252,15 +260,21 @@ def fallback_func() -> bool: error_message=None, flag_metadata={}, ) + except ( + FlagNotFoundError, + TypeMismatchError, + ParseError, + GeneralError, + ): + # Re-raise specific OpenFeature exceptions + raise + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + raise FlagNotFoundError(f"Flag not found: {e}") + else: + raise GeneralError(f"HTTP error: {e}") except Exception as e: - return FlagResolutionDetails( - value=default_value, - reason=Reason.ERROR, - variant=None, - error_code=ErrorCode.GENERAL, - error_message=str(e), - flag_metadata={}, - ) + raise GeneralError(f"Unexpected error: {e}") async def resolve_string_details_async( self, @@ -316,7 +330,10 @@ async def resolve_object_details_async( def object_converter(payload_value: Any) -> Union[dict, list]: if isinstance(payload_value, str): - value = json.loads(payload_value) + try: + value = json.loads(payload_value) + except json.JSONDecodeError as e: + raise ParseError(str(e)) else: value = payload_value diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 19314e89..ba326062 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -1,10 +1,16 @@ import pytest +import requests from unittest.mock import Mock, patch from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import Reason -from openfeature.exception import ErrorCode +from openfeature.exception import ( + FlagNotFoundError, + GeneralError, + ParseError, + TypeMismatchError, +) def test_unleash_provider_import(): @@ -69,7 +75,9 @@ def test_unleash_provider_resolve_boolean_details(unleash_provider_client): def test_unleash_provider_resolve_boolean_details_error(): """Test that UnleashProvider handles errors gracefully.""" mock_client = Mock() - mock_client.is_enabled.side_effect = Exception("Connection error") + mock_client.is_enabled.side_effect = requests.exceptions.ConnectionError( + "Connection error" + ) with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -80,22 +88,36 @@ def test_unleash_provider_resolve_boolean_details_error(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_boolean_details("test_flag", True) - assert flag.value is True - assert flag.reason == Reason.ERROR - assert flag.error_code == ErrorCode.GENERAL - assert flag.error_message == "Connection error" + with pytest.raises(GeneralError) as exc_info: + provider.resolve_boolean_details("test_flag", True) + assert "Connection error" in str(exc_info.value) provider.shutdown() -def test_unleash_provider_resolve_string_details(): - """Test that UnleashProvider can resolve string flags.""" +@pytest.mark.parametrize( + "method_name, payload_value, expected_value, default_value", + [ + ("resolve_string_details", "test-string", "test-string", "default"), + ("resolve_integer_details", "42", 42, 0), + ("resolve_float_details", "3.14", 3.14, 0.0), + ( + "resolve_object_details", + '{"key": "value", "number": 42}', + {"key": "value", "number": 42}, + {"default": "value"}, + ), + ], +) +def test_unleash_provider_resolve_variant_flags( + method_name, payload_value, expected_value, default_value +): + """Test that UnleashProvider can resolve variant-based flags.""" mock_client = Mock() mock_client.get_variant.return_value = { "enabled": True, "name": "test-variant", - "payload": {"value": "test-string"}, + "payload": {"value": payload_value}, } with patch( @@ -107,48 +129,21 @@ def test_unleash_provider_resolve_string_details(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_string_details("test_flag", "default") - assert flag.value == "test-string" - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" - - provider.shutdown() - - -def test_unleash_provider_resolve_integer_details(): - """Test that UnleashProvider can resolve integer flags.""" - mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "42"}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) + method = getattr(provider, method_name) + flag = method("test_flag", default_value) - flag = provider.resolve_integer_details("test_flag", 0) - assert flag.value == 42 + assert flag.value == expected_value assert flag.reason == Reason.TARGETING_MATCH assert flag.variant == "test-variant" provider.shutdown() -def test_unleash_provider_resolve_float_details(): - """Test that UnleashProvider can resolve float flags.""" +@pytest.mark.asyncio +async def test_unleash_provider_resolve_boolean_details_async(): + """Test that UnleashProvider can resolve boolean flags asynchronously.""" mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "3.14"}, - } + mock_client.is_enabled.return_value = True with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -159,21 +154,42 @@ def test_unleash_provider_resolve_float_details(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_float_details("test_flag", 0.0) - assert flag.value == 3.14 + flag = await provider.resolve_boolean_details_async("test_flag", False) + assert flag.value is True assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" provider.shutdown() -def test_unleash_provider_resolve_object_details(): - """Test that UnleashProvider can resolve object flags.""" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method_name, payload_value, expected_value, default_value", + [ + ( + "resolve_string_details_async", + "test-string", + "test-string", + "default", + ), + ("resolve_integer_details_async", "42", 42, 0), + ("resolve_float_details_async", "3.14", 3.14, 0.0), + ( + "resolve_object_details_async", + '{"key": "value", "number": 42}', + {"key": "value", "number": 42}, + {"default": "value"}, + ), + ], +) +async def test_unleash_provider_resolve_variant_flags_async( + method_name, payload_value, expected_value, default_value +): + """Test that UnleashProvider can resolve variant-based flags asynchronously.""" mock_client = Mock() mock_client.get_variant.return_value = { "enabled": True, "name": "test-variant", - "payload": {"value": '{"key": "value", "number": 42}'}, + "payload": {"value": payload_value}, } with patch( @@ -185,17 +201,18 @@ def test_unleash_provider_resolve_object_details(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_object_details("test_flag", {"default": "value"}) - assert flag.value == {"key": "value", "number": 42} + method = getattr(provider, method_name) + flag = await method("test_flag", default_value) + + assert flag.value == expected_value assert flag.reason == Reason.TARGETING_MATCH assert flag.variant == "test-variant" provider.shutdown() -@pytest.mark.asyncio -async def test_unleash_provider_resolve_boolean_details_async(): - """Test that UnleashProvider can resolve boolean flags asynchronously.""" +def test_unleash_provider_with_evaluation_context(): + """Test that UnleashProvider uses evaluation context correctly.""" mock_client = Mock() mock_client.is_enabled.return_value = True @@ -208,21 +225,56 @@ async def test_unleash_provider_resolve_boolean_details_async(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = await provider.resolve_boolean_details_async("test_flag", False) + context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "country": "US"}, + ) + + flag = provider.resolve_boolean_details("test_flag", False, context) assert flag.value is True - assert flag.reason == Reason.TARGETING_MATCH + + # Verify that context was passed to UnleashClient + mock_client.is_enabled.assert_called_with( + "test_flag", + context={"userId": "user123", "email": "user@example.com", "country": "US"}, + fallback_function=mock_client.is_enabled.call_args[1]["fallback_function"], + ) provider.shutdown() -@pytest.mark.asyncio -async def test_unleash_provider_resolve_string_details_async(): - """Test that UnleashProvider can resolve string flags asynchronously.""" +@pytest.mark.parametrize( + "method_name, payload_value, default_value, expected_error_message, expected_exception", + [ + ( + "resolve_integer_details", + "not-a-number", + 0, + "invalid literal for int()", + "TypeMismatchError", + ), + ( + "resolve_object_details", + "invalid-json{", + {"default": "value"}, + "Expecting value", + "ParseError", + ), + ], +) +def test_unleash_provider_value_conversion_errors( + method_name, + payload_value, + default_value, + expected_error_message, + expected_exception, +): + """Test that UnleashProvider handles value conversion errors correctly.""" mock_client = Mock() mock_client.get_variant.return_value = { "enabled": True, "name": "test-variant", - "payload": {"value": "test-string"}, + "payload": {"value": payload_value}, } with patch( @@ -234,23 +286,55 @@ async def test_unleash_provider_resolve_string_details_async(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = await provider.resolve_string_details_async("test_flag", "default") - assert flag.value == "test-string" - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" + method = getattr(provider, method_name) + + if expected_exception == "TypeMismatchError": + with pytest.raises(TypeMismatchError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) + elif expected_exception == "ParseError": + with pytest.raises(ParseError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) provider.shutdown() -@pytest.mark.asyncio -async def test_unleash_provider_resolve_integer_details_async(): - """Test that UnleashProvider can resolve integer flags asynchronously.""" +@pytest.mark.parametrize( + "method_name, mock_side_effect, default_value, expected_error_message, expected_exception", + [ + ( + "resolve_string_details", + requests.exceptions.HTTPError( + "404 Client Error: Not Found", response=Mock(status_code=404) + ), + "default", + "Flag not found", + "FlagNotFoundError", + ), + ( + "resolve_boolean_details", + requests.exceptions.ConnectionError("Connection error"), + True, + "Connection error", + "GeneralError", + ), + ], +) +def test_unleash_provider_general_errors( + method_name, + mock_side_effect, + default_value, + expected_error_message, + expected_exception, +): + """Test that UnleashProvider handles general errors correctly.""" mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "42"}, - } + + if method_name == "resolve_boolean_details": + mock_client.is_enabled.side_effect = mock_side_effect + else: + mock_client.get_variant.side_effect = mock_side_effect with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -261,22 +345,28 @@ async def test_unleash_provider_resolve_integer_details_async(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = await provider.resolve_integer_details_async("test_flag", 0) - assert flag.value == 42 - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" + method = getattr(provider, method_name) + + if expected_exception == "FlagNotFoundError": + with pytest.raises(FlagNotFoundError) as exc_info: + method("non_existent_flag", default_value) + assert expected_error_message in str(exc_info.value) + else: + with pytest.raises(GeneralError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) provider.shutdown() -@pytest.mark.asyncio -async def test_unleash_provider_resolve_float_details_async(): - """Test that UnleashProvider can resolve float flags asynchronously.""" +def test_unleash_provider_edge_cases(): + """Test UnleashProvider with edge cases and boundary conditions.""" mock_client = Mock() + mock_client.is_enabled.return_value = True mock_client.get_variant.return_value = { "enabled": True, - "name": "test-variant", - "payload": {"value": "3.14"}, + "name": "edge-variant", + "payload": {"value": "edge-value"}, } with patch( @@ -288,23 +378,49 @@ async def test_unleash_provider_resolve_float_details_async(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = await provider.resolve_float_details_async("test_flag", 0.0) - assert flag.value == 3.14 - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" + # Test with empty string flag key + result = provider.resolve_string_details("", "default") + assert result.value == "edge-value" + + # Test with very long flag key + long_key = "a" * 1000 + result = provider.resolve_string_details(long_key, "default") + assert result.value == "edge-value" + + # Test with special characters in flag key + special_key = "flag-with-special-chars!@#$%^&*()" + result = provider.resolve_string_details(special_key, "default") + assert result.value == "edge-value" + + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "edge-value" provider.shutdown() -@pytest.mark.asyncio -async def test_unleash_provider_resolve_object_details_async(): - """Test that UnleashProvider can resolve object flags asynchronously.""" +@pytest.mark.parametrize( + "mock_side_effect, expected_error_message", + [ + ( + requests.exceptions.HTTPError( + "429 Too Many Requests", response=Mock(status_code=429) + ), + "HTTP error", + ), + ( + requests.exceptions.Timeout("Request timeout"), + "Unexpected error", + ), + ( + requests.exceptions.SSLError("SSL certificate error"), + "Unexpected error", + ), + ], +) +def test_unleash_provider_network_errors(mock_side_effect, expected_error_message): + """Test that UnleashProvider handles network errors correctly.""" mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": '{"key": "value", "number": 42}'}, - } + mock_client.is_enabled.side_effect = mock_side_effect with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -315,18 +431,15 @@ async def test_unleash_provider_resolve_object_details_async(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = await provider.resolve_object_details_async( - "test_flag", {"default": "value"} - ) - assert flag.value == {"key": "value", "number": 42} - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" + with pytest.raises(GeneralError) as exc_info: + provider.resolve_boolean_details("test_flag", True) + assert expected_error_message in str(exc_info.value) provider.shutdown() -def test_unleash_provider_with_evaluation_context(): - """Test that UnleashProvider uses evaluation context correctly.""" +def test_unleash_provider_context_without_targeting_key(): + """Test that UnleashProvider works with context without targeting key.""" mock_client = Mock() mock_client.is_enabled.return_value = True @@ -339,32 +452,19 @@ def test_unleash_provider_with_evaluation_context(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "country": "US"}, - ) - - flag = provider.resolve_boolean_details("test_flag", False, context) - assert flag.value is True - - # Verify that context was passed to UnleashClient - mock_client.is_enabled.assert_called_with( - "test_flag", - context={"userId": "user123", "email": "user@example.com", "country": "US"}, - fallback_function=mock_client.is_enabled.call_args[1]["fallback_function"], + context = EvaluationContext(attributes={"user_id": "123", "role": "admin"}) + result = provider.resolve_boolean_details( + "test_flag", False, evaluation_context=context ) + assert result.value is True provider.shutdown() -def test_unleash_provider_type_mismatch_error(): - """Test that UnleashProvider handles type mismatch errors correctly.""" +def test_unleash_provider_context_with_targeting_key(): + """Test that UnleashProvider correctly maps targeting key to userId.""" mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "not-a-number"}, - } + mock_client.is_enabled.return_value = True with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -375,46 +475,26 @@ def test_unleash_provider_type_mismatch_error(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_integer_details("test_flag", 0) - assert flag.value == 0 - assert flag.reason == Reason.ERROR - assert flag.error_code == ErrorCode.TYPE_MISMATCH - assert "invalid literal for int()" in flag.error_message - - provider.shutdown() - - -def test_unleash_provider_parse_error(): - """Test that UnleashProvider handles parse errors correctly.""" - mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "invalid-json{"}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" + context = EvaluationContext( + targeting_key="user123", attributes={"role": "admin", "region": "us-east"} ) + result = provider.resolve_boolean_details( + "test_flag", False, evaluation_context=context + ) + assert result.value is True - flag = provider.resolve_object_details("test_flag", {"default": "value"}) - assert flag.value == {"default": "value"} - assert flag.reason == Reason.ERROR - assert flag.error_code == ErrorCode.TYPE_MISMATCH - assert "Expecting value" in flag.error_message + mock_client.is_enabled.assert_called_once() + call_args = mock_client.is_enabled.call_args + assert call_args[1]["context"]["userId"] == "user123" + assert call_args[1]["context"]["role"] == "admin" + assert call_args[1]["context"]["region"] == "us-east" provider.shutdown() -def test_unleash_provider_invalid_context_error(): - """Test that UnleashProvider handles invalid context errors correctly.""" +def test_unleash_provider_variant_flag_scenarios(): + """Test various variant flag scenarios.""" mock_client = Mock() - mock_client.is_enabled.side_effect = Exception("Invalid context provided") with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -425,23 +505,52 @@ def test_unleash_provider_invalid_context_error(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - context = EvaluationContext( - targeting_key="user123", attributes={"invalid": "context"} - ) - - flag = provider.resolve_boolean_details("test_flag", True, context) - assert flag.value is True - assert flag.reason == Reason.ERROR - assert flag.error_code == ErrorCode.GENERAL - assert "Invalid context provided" in flag.error_message + mock_client.get_variant.return_value = { + "enabled": False, + "name": "disabled-variant", + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant is None + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "enabled-variant", + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant is None + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "variant-value"}, + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "variant-value" + assert result.reason == Reason.TARGETING_MATCH + assert result.variant == "test-variant" + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {}, + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant == "test-variant" provider.shutdown() -def test_unleash_provider_flag_not_found_error(): - """Test that UnleashProvider handles flag not found errors correctly.""" +def test_unleash_provider_type_validation(): + """Test that UnleashProvider handles type validation correctly.""" mock_client = Mock() - mock_client.get_variant.side_effect = Exception("Flag not found") + # Mock returning wrong type for boolean flag + mock_client.is_enabled.return_value = "not-a-boolean" with patch( "openfeature.contrib.provider.unleash.UnleashClient" @@ -452,10 +561,8 @@ def test_unleash_provider_flag_not_found_error(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - flag = provider.resolve_string_details("non_existent_flag", "default") - assert flag.value == "default" - assert flag.reason == Reason.ERROR - assert flag.error_code == ErrorCode.GENERAL - assert "Flag not found" in flag.error_message + result = provider.resolve_boolean_details("test_flag", False) + assert result.value == "not-a-boolean" + assert isinstance(result.value, str) provider.shutdown() diff --git a/uv.lock b/uv.lock index 9bfc2ac0..ceb983f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1013,6 +1013,7 @@ dev = [ { name = "mypy", extra = ["faster-cache"] }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "types-requests" }, ] [package.metadata] @@ -1027,6 +1028,7 @@ dev = [ { name = "mypy", extras = ["faster-cache"], specifier = ">=1.17.0,<2.0.0" }, { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "types-requests", specifier = ">=2.31.0" }, ] [[package]] From 7f69d020273bd2c5138f96cec33ecd98d31c4d4c Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 15:19:57 +0700 Subject: [PATCH 08/29] feat(provider): add flag metadata and Unleash provider initializer Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 4 + .../contrib/provider/unleash/__init__.py | 96 ++++++++++++++-- .../tests/conftest.py | 1 + .../tests/test_provider.py | 104 ++++++++++++++++++ 4 files changed, 193 insertions(+), 12 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 2de97c43..15aa97ad 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -23,6 +23,9 @@ provider = UnleashProvider( api_token="your-api-token" ) +# Initialize the provider (required before use) +provider.initialize() + api.set_provider(provider) ``` @@ -44,6 +47,7 @@ provider = UnleashProvider( app_name="my-app", api_token="my-token" ) +provider.initialize() api.set_provider(provider) # Get a client and evaluate flags diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index c1fded0e..92822b14 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -4,7 +4,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason from openfeature.hook import Hook -from openfeature.provider import AbstractProvider, Metadata +from openfeature.provider import AbstractProvider, Metadata, ProviderStatus from openfeature.exception import ( FlagNotFoundError, GeneralError, @@ -31,10 +31,36 @@ def __init__( app_name: The application name api_token: The API token for authentication """ - self.client = UnleashClient( - url=url, app_name=app_name, custom_headers={"Authorization": api_token} - ) - self.client.initialize_client() + self.url = url + self.app_name = app_name + self.api_token = api_token + self.client: Optional[UnleashClient] = None + self._status = ProviderStatus.NOT_READY + self._last_context: Optional[EvaluationContext] = None + + def initialize( + self, evaluation_context: Optional[EvaluationContext] = None + ) -> None: + """Initialize the Unleash provider. + + Args: + evaluation_context: Optional evaluation context (not used for initialization) + """ + try: + self.client = UnleashClient( + url=self.url, + app_name=self.app_name, + custom_headers={"Authorization": self.api_token}, + ) + self.client.initialize_client() + self._status = ProviderStatus.READY + except Exception as e: + self._status = ProviderStatus.ERROR + raise GeneralError(f"Failed to initialize Unleash provider: {e}") from e + + def get_status(self) -> ProviderStatus: + """Get the current status of the provider.""" + return self._status def get_metadata(self) -> Metadata: """Get provider metadata.""" @@ -46,8 +72,27 @@ def get_provider_hooks(self) -> List[Hook]: def shutdown(self) -> None: """Shutdown the Unleash client.""" - if hasattr(self, "client"): - self.client.destroy() + if self.client: + try: + self.client.destroy() + self.client = None + self._status = ProviderStatus.NOT_READY + except Exception as e: + self._status = ProviderStatus.ERROR + raise GeneralError(f"Failed to shutdown Unleash provider: {e}") from e + + def on_context_changed( + self, + old_context: Optional[EvaluationContext], + new_context: Optional[EvaluationContext], + ) -> None: + """Handle evaluation context changes. + + Args: + old_context: The previous evaluation context + new_context: The new evaluation context + """ + self._last_context = new_context def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None @@ -75,11 +120,14 @@ def _resolve_variant_flag( flag_key: The flag key to resolve default_value: The default value to return if flag is disabled value_converter: Function to convert payload value to desired type - evaluation_context: Optional evaluation context (ignored for now) + evaluation_context: Optional evaluation context Returns: FlagResolutionDetails with the resolved value """ + if not self.client: + raise GeneralError("Provider not initialized. Call initialize() first.") + try: # Use get_variant to get the variant payload context = self._build_unleash_context(evaluation_context) @@ -100,7 +148,12 @@ def _resolve_variant_flag( variant=variant.get("name"), error_code=None, error_message=None, - flag_metadata={}, + flag_metadata={ + "source": "unleash", + "enabled": variant.get("enabled", False), + "variant_name": variant.get("name") or "", + "app_name": self.app_name, + }, ) except (ValueError, TypeError) as e: # If payload value can't be converted, raise TypeMismatchError @@ -115,7 +168,12 @@ def _resolve_variant_flag( variant=None, error_code=None, error_message=None, - flag_metadata={}, + flag_metadata={ + "source": "unleash", + "enabled": variant.get("enabled", False), + "variant_name": variant.get("name") or "", + "app_name": self.app_name, + }, ) except ( FlagNotFoundError, @@ -140,6 +198,9 @@ def resolve_boolean_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: """Resolve boolean flag details.""" + if not self.client: + raise GeneralError("Provider not initialized. Call initialize() first.") + try: def fallback_func() -> bool: @@ -157,7 +218,11 @@ def fallback_func() -> bool: variant=None, error_code=None, error_message=None, - flag_metadata={}, + flag_metadata={ + "source": "unleash", + "enabled": value, + "app_name": self.app_name, + }, ) except ( FlagNotFoundError, @@ -241,6 +306,9 @@ async def resolve_boolean_details_async( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: """Resolve boolean flag details asynchronously.""" + if not self.client: + raise GeneralError("Provider not initialized. Call initialize() first.") + try: def fallback_func() -> bool: @@ -258,7 +326,11 @@ def fallback_func() -> bool: variant=None, error_code=None, error_message=None, - flag_metadata={}, + flag_metadata={ + "source": "unleash", + "enabled": value, + "app_name": self.app_name, + }, ) except ( FlagNotFoundError, diff --git a/providers/openfeature-provider-unleash/tests/conftest.py b/providers/openfeature-provider-unleash/tests/conftest.py index bb42cca8..22169c75 100644 --- a/providers/openfeature-provider-unleash/tests/conftest.py +++ b/providers/openfeature-provider-unleash/tests/conftest.py @@ -19,6 +19,7 @@ def unleash_provider_client(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() api.set_provider(provider) yield api.get_client() provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index ba326062..2b4e5e11 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -5,6 +5,7 @@ from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus from openfeature.exception import ( FlagNotFoundError, GeneralError, @@ -24,6 +25,7 @@ def test_unleash_provider_instantiation(): url="http://localhost:4242", app_name="test-app", api_token="test-token" ) assert provider is not None + assert provider.get_status() == ProviderStatus.NOT_READY provider.shutdown() @@ -37,6 +39,33 @@ def test_unleash_provider_get_metadata(): provider.shutdown() +def test_unleash_provider_initialization(): + """Test that UnleashProvider can be initialized properly.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Should start as NOT_READY + assert provider.get_status() == ProviderStatus.NOT_READY + + # Initialize the provider + provider.initialize() + + # Should be READY after initialization + assert provider.get_status() == ProviderStatus.READY + assert provider.client is not None + + provider.shutdown() + + def test_unleash_provider_all_methods_implemented(): """Test that all UnleashProvider methods are implemented.""" provider = UnleashProvider( @@ -87,6 +116,7 @@ def test_unleash_provider_resolve_boolean_details_error(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() with pytest.raises(GeneralError) as exc_info: provider.resolve_boolean_details("test_flag", True) @@ -128,6 +158,7 @@ def test_unleash_provider_resolve_variant_flags( provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() method = getattr(provider, method_name) flag = method("test_flag", default_value) @@ -153,6 +184,7 @@ async def test_unleash_provider_resolve_boolean_details_async(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() flag = await provider.resolve_boolean_details_async("test_flag", False) assert flag.value is True @@ -200,6 +232,7 @@ async def test_unleash_provider_resolve_variant_flags_async( provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() method = getattr(provider, method_name) flag = await method("test_flag", default_value) @@ -224,6 +257,7 @@ def test_unleash_provider_with_evaluation_context(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() context = EvaluationContext( targeting_key="user123", @@ -285,6 +319,7 @@ def test_unleash_provider_value_conversion_errors( provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() method = getattr(provider, method_name) @@ -344,6 +379,7 @@ def test_unleash_provider_general_errors( provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() method = getattr(provider, method_name) @@ -377,6 +413,7 @@ def test_unleash_provider_edge_cases(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() # Test with empty string flag key result = provider.resolve_string_details("", "default") @@ -430,6 +467,7 @@ def test_unleash_provider_network_errors(mock_side_effect, expected_error_messag provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() with pytest.raises(GeneralError) as exc_info: provider.resolve_boolean_details("test_flag", True) @@ -438,6 +476,68 @@ def test_unleash_provider_network_errors(mock_side_effect, expected_error_messag provider.shutdown() +def test_unleash_provider_context_changed(): + """Test that UnleashProvider handles context changes correctly.""" + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Initially no context + assert provider._last_context is None + + # Set initial context + context1 = EvaluationContext(targeting_key="user1", attributes={"role": "admin"}) + provider.on_context_changed(None, context1) + assert provider._last_context == context1 + + # Change context + context2 = EvaluationContext(targeting_key="user2", attributes={"role": "user"}) + provider.on_context_changed(context1, context2) + assert provider._last_context == context2 + + # Clear context + provider.on_context_changed(context2, None) + assert provider._last_context is None + + provider.shutdown() + + +def test_unleash_provider_flag_metadata(): + """Test that UnleashProvider includes flag metadata in resolution details.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "test-value"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + # Test boolean flag metadata + result = provider.resolve_boolean_details("test_flag", False) + assert result.flag_metadata["source"] == "unleash" + assert result.flag_metadata["enabled"] is True + assert result.flag_metadata["app_name"] == "test-app" + + # Test variant flag metadata + result = provider.resolve_string_details("test_flag", "default") + assert result.flag_metadata["source"] == "unleash" + assert result.flag_metadata["enabled"] is True + assert result.flag_metadata["variant_name"] == "test-variant" + assert result.flag_metadata["app_name"] == "test-app" + + provider.shutdown() + + def test_unleash_provider_context_without_targeting_key(): """Test that UnleashProvider works with context without targeting key.""" mock_client = Mock() @@ -451,6 +551,7 @@ def test_unleash_provider_context_without_targeting_key(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() context = EvaluationContext(attributes={"user_id": "123", "role": "admin"}) result = provider.resolve_boolean_details( @@ -474,6 +575,7 @@ def test_unleash_provider_context_with_targeting_key(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() context = EvaluationContext( targeting_key="user123", attributes={"role": "admin", "region": "us-east"} @@ -504,6 +606,7 @@ def test_unleash_provider_variant_flag_scenarios(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() mock_client.get_variant.return_value = { "enabled": False, @@ -560,6 +663,7 @@ def test_unleash_provider_type_validation(): provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) + provider.initialize() result = provider.resolve_boolean_details("test_flag", False) assert result.value == "not-a-boolean" From 2710c49ec2eb2ddefcecb0c3e8cee7a2ba2478fd Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 15:37:57 +0700 Subject: [PATCH 09/29] feat(provider): implement Unleash provider events Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 93 ++++++++------ .../contrib/provider/unleash/__init__.py | 78 ++++++++++++ .../tests/test_provider.py | 115 ++++++++++++++++++ 3 files changed, 247 insertions(+), 39 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 15aa97ad..d0240e46 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -35,16 +35,47 @@ api.set_provider(provider) - `app_name`: The name of your application - `api_token`: The API token for authentication +### Event handling + +The Unleash provider supports OpenFeature events for monitoring provider state changes: + +```python +from openfeature.event import ProviderEvent + +def on_provider_ready(event_details): + print(f"Provider {event_details['provider_name']} is ready") + +def on_provider_error(event_details): + print(f"Provider error: {event_details['error_message']}") + +def on_configuration_changed(event_details): + print(f"Configuration changed, flags: {event_details.get('flag_keys', [])}") + +# Add event handlers +provider.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) +provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_provider_error) +provider.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_configuration_changed) + +# Remove event handlers +provider.remove_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) +``` + +**Supported events:** +- `ProviderEvent.PROVIDER_READY`: Emitted when the provider is ready to evaluate flags +- `ProviderEvent.PROVIDER_ERROR`: Emitted when the provider encounters an error +- `ProviderEvent.PROVIDER_CONFIGURATION_CHANGED`: Emitted when flag configurations are updated +- `ProviderEvent.PROVIDER_STALE`: Emitted when the provider's cached state is no longer valid + ### Example usage ```python from openfeature import api from openfeature.contrib.provider.unleash import UnleashProvider -# Set up the provider +# Initialize the provider provider = UnleashProvider( - url="https://unleash.example.com", - app_name="my-app", + url="https://your-unleash-instance.com", + app_name="my-python-app", api_token="my-token" ) provider.initialize() @@ -53,47 +84,31 @@ api.set_provider(provider) # Get a client and evaluate flags client = api.get_client() -# Resolve different types of flags (synchronous) -boolean_flag = client.get_boolean_details("my-boolean-flag", default_value=False) -string_flag = client.get_string_details("my-string-flag", default_value="default") -integer_flag = client.get_integer_details("my-integer-flag", default_value=0) -float_flag = client.get_float_details("my-float-flag", default_value=0.0) -object_flag = client.get_object_details("my-object-flag", default_value={"key": "value"}) - -# Resolve different types of flags (asynchronous) -boolean_flag_async = await client.get_boolean_details_async("my-boolean-flag", default_value=False) -string_flag_async = await client.get_string_details_async("my-string-flag", default_value="default") -integer_flag_async = await client.get_integer_details_async("my-integer-flag", default_value=0) -float_flag_async = await client.get_float_details_async("my-float-flag", default_value=0.0) -object_flag_async = await client.get_object_details_async("my-object-flag", default_value={"key": "value"}) - -# Using evaluation context for targeting -from openfeature.evaluation_context import EvaluationContext - -context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "country": "US", "plan": "premium"} -) +# Boolean flag evaluation +is_enabled = client.get_boolean_value("my-feature", False) +print(f"Feature is enabled: {is_enabled}") + +# String flag evaluation with context +context = {"userId": "user123", "sessionId": "session456"} +variant = client.get_string_value("my-variant-flag", "default", context) +print(f"Variant: {variant}") -# Resolve flags with context (synchronous) -boolean_with_context = client.get_boolean_details("my-boolean-flag", default_value=False, evaluation_context=context) -string_with_context = client.get_string_details("my-string-flag", default_value="default", evaluation_context=context) +# Shutdown when done +provider.shutdown() +``` -# Resolve flags with context (asynchronous) -boolean_async_with_context = await client.get_boolean_details_async("my-boolean-flag", default_value=False, evaluation_context=context) -string_async_with_context = await client.get_string_details_async("my-string-flag", default_value="default", evaluation_context=context) +## Development -# Check flag values -if boolean_flag.value: - print("Boolean feature is enabled!") +### Running tests -print(f"String value: {string_flag.value}") -print(f"Integer value: {integer_flag.value}") -print(f"Float value: {float_flag.value}") -print(f"Object value: {object_flag.value}") +```bash +pytest +``` -# Clean up when done -provider.shutdown() +### Type checking + +```bash +mypy src/ ``` ## License diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 92822b14..efb3ac27 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -5,14 +5,17 @@ from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata, ProviderStatus +from openfeature.event import ProviderEvent from openfeature.exception import ( FlagNotFoundError, GeneralError, ParseError, TypeMismatchError, + ErrorCode, ) import requests from UnleashClient import UnleashClient +from UnleashClient.events import BaseEvent, UnleashReadyEvent, UnleashFetchedEvent __all__ = ["UnleashProvider"] @@ -37,6 +40,12 @@ def __init__( self.client: Optional[UnleashClient] = None self._status = ProviderStatus.NOT_READY self._last_context: Optional[EvaluationContext] = None + self._event_handlers: dict[ProviderEvent, List[Callable]] = { + ProviderEvent.PROVIDER_READY: [], + ProviderEvent.PROVIDER_ERROR: [], + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED: [], + ProviderEvent.PROVIDER_STALE: [], + } def initialize( self, evaluation_context: Optional[EvaluationContext] = None @@ -51,11 +60,18 @@ def initialize( url=self.url, app_name=self.app_name, custom_headers={"Authorization": self.api_token}, + event_callback=self._unleash_event_callback, ) self.client.initialize_client() self._status = ProviderStatus.READY + self._emit_event(ProviderEvent.PROVIDER_READY) except Exception as e: self._status = ProviderStatus.ERROR + self._emit_event( + ProviderEvent.PROVIDER_ERROR, + error_message=str(e), + error_code=ErrorCode.GENERAL, + ) raise GeneralError(f"Failed to initialize Unleash provider: {e}") from e def get_status(self) -> ProviderStatus: @@ -79,6 +95,11 @@ def shutdown(self) -> None: self._status = ProviderStatus.NOT_READY except Exception as e: self._status = ProviderStatus.ERROR + self._emit_event( + ProviderEvent.PROVIDER_ERROR, + error_message=str(e), + error_code=ErrorCode.GENERAL, + ) raise GeneralError(f"Failed to shutdown Unleash provider: {e}") from e def on_context_changed( @@ -94,6 +115,63 @@ def on_context_changed( """ self._last_context = new_context + def add_handler(self, event_type: ProviderEvent, handler: Callable) -> None: + """Add an event handler for a specific event type. + + Args: + event_type: The type of event to handle + handler: The handler function to call + """ + if event_type in self._event_handlers: + self._event_handlers[event_type].append(handler) + + def remove_handler(self, event_type: ProviderEvent, handler: Callable) -> None: + """Remove an event handler for a specific event type. + + Args: + event_type: The type of event to handle + handler: The handler function to remove + """ + if ( + event_type in self._event_handlers + and handler in self._event_handlers[event_type] + ): + self._event_handlers[event_type].remove(handler) + + def _emit_event(self, event_type: ProviderEvent, **kwargs: Any) -> None: + """Emit an event to all registered handlers. + + Args: + event_type: The type of event to emit + **kwargs: Additional event data + """ + if event_type in self._event_handlers: + event_details = {"provider_name": self.get_metadata().name, **kwargs} + for handler in self._event_handlers[event_type]: + try: + handler(event_details) + except Exception: + # Ignore handler errors to prevent breaking other handlers + pass + + def _unleash_event_callback(self, event: BaseEvent) -> None: + """Callback for UnleashClient events. + + Args: + event: The Unleash event + """ + if isinstance(event, UnleashReadyEvent): + self._status = ProviderStatus.READY + self._emit_event(ProviderEvent.PROVIDER_READY) + elif isinstance(event, UnleashFetchedEvent): + # Configuration changed when features are fetched + self._emit_event( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + flag_keys=( + list(event.features.keys()) if hasattr(event, "features") else [] + ), + ) + def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None ) -> Optional[dict[str, Any]]: diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 2b4e5e11..5d3fd8dc 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -6,6 +6,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import Reason from openfeature.provider import ProviderStatus +from openfeature.event import ProviderEvent from openfeature.exception import ( FlagNotFoundError, GeneralError, @@ -538,6 +539,120 @@ def test_unleash_provider_flag_metadata(): provider.shutdown() +def test_unleash_provider_events(): + """Test that UnleashProvider supports event handling.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Test event handler registration + ready_events = [] + error_events = [] + config_events = [] + + def on_ready(event_details): + ready_events.append(event_details) + + def on_error(event_details): + error_events.append(event_details) + + def on_config_changed(event_details): + config_events.append(event_details) + + provider.add_handler(ProviderEvent.PROVIDER_READY, on_ready) + provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) + provider.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_config_changed + ) + + # Initialize should emit PROVIDER_READY + provider.initialize() + assert len(ready_events) == 1 + assert ready_events[0]["provider_name"] == "Unleash Provider" + + # Test error event emission + mock_client.initialize_client.side_effect = Exception("Test error") + error_provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + error_provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) + + with pytest.raises(GeneralError): + error_provider.initialize() + + assert len(error_events) == 1 + assert "Test error" in error_events[0]["error_message"] + + # Test handler removal + provider.remove_handler(ProviderEvent.PROVIDER_READY, on_ready) + provider.shutdown() # Should not trigger ready event again + + provider.shutdown() + + +def test_unleash_provider_unleash_event_callback(): + """Test that UnleashProvider handles UnleashClient events correctly.""" + import uuid + from UnleashClient.events import ( + UnleashReadyEvent, + UnleashFetchedEvent, + UnleashEventType, + ) + + mock_client = Mock() + mock_client.initialize_client.return_value = None + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Initialize to trigger UnleashClient creation + provider.initialize() + + # Capture the event callback + event_callback = mock_unleash_client.call_args[1]["event_callback"] + + # Test UnleashReadyEvent + ready_events = [] + provider.add_handler( + ProviderEvent.PROVIDER_READY, lambda details: ready_events.append(details) + ) + + event_callback(UnleashReadyEvent(UnleashEventType.READY, uuid.uuid4())) + assert len(ready_events) == 1 + assert provider.get_status() == ProviderStatus.READY + + # Test UnleashFetchedEvent + config_events = [] + provider.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + lambda details: config_events.append(details), + ) + + # Create a mock UnleashFetchedEvent with features + mock_event = Mock(spec=UnleashFetchedEvent) + mock_event.features = {"flag1": {}, "flag2": {}} + + event_callback(mock_event) + assert len(config_events) == 1 + assert config_events[0]["flag_keys"] == ["flag1", "flag2"] + + provider.shutdown() + + def test_unleash_provider_context_without_targeting_key(): """Test that UnleashProvider works with context without targeting key.""" mock_client = Mock() From 6c978818acb0b5379c06a3e4aa48ca4286f684aa Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 16:10:17 +0700 Subject: [PATCH 10/29] feat(provider): implement Unleash impression tracking Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 49 +++- .../contrib/provider/unleash/__init__.py | 47 +++- .../tests/test_provider.py | 241 +++++++++++++++++- 3 files changed, 328 insertions(+), 9 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index d0240e46..5ca5a5e6 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -54,7 +54,9 @@ def on_configuration_changed(event_details): # Add event handlers provider.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_provider_error) -provider.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_configuration_changed) +provider.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_configuration_changed +) # Remove event handlers provider.remove_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) @@ -66,11 +68,47 @@ provider.remove_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) - `ProviderEvent.PROVIDER_CONFIGURATION_CHANGED`: Emitted when flag configurations are updated - `ProviderEvent.PROVIDER_STALE`: Emitted when the provider's cached state is no longer valid +### Tracking support + +The Unleash provider supports OpenFeature tracking for A/B testing and analytics: + +```python +from openfeature.evaluation_context import EvaluationContext + +# Basic tracking +provider.track("page_view") + +# Tracking with context +context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "country": "US"} +) +provider.track("button_click", context) + +# Tracking with event details +event_details = { + "value": 99.99, + "currency": "USD", + "category": "purchase" +} +provider.track("purchase_completed", event_details=event_details) + +# Tracking with both context and details +provider.track("conversion", context, event_details) +``` + +**Tracking features:** +- **Event Names**: Track user actions or application states +- **Evaluation Context**: Include user targeting information +- **Event Details**: Add numeric values and custom fields for analytics +- **Unleash Integration**: Uses UnleashClient's impression event infrastructure + ### Example usage ```python from openfeature import api from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext # Initialize the provider provider = UnleashProvider( @@ -93,6 +131,15 @@ context = {"userId": "user123", "sessionId": "session456"} variant = client.get_string_value("my-variant-flag", "default", context) print(f"Variant: {variant}") +# Track user actions for A/B testing +user_context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "plan": "premium"} +) + +provider.track("feature_experiment_view", user_context) +provider.track("conversion", user_context, {"value": 150.0, "currency": "USD"}) + # Shutdown when done provider.shutdown() ``` diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index efb3ac27..31c232af 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,4 +1,5 @@ import json +import uuid from typing import Any, Callable, List, Mapping, Optional, Sequence, Union from openfeature.evaluation_context import EvaluationContext @@ -15,7 +16,13 @@ ) import requests from UnleashClient import UnleashClient -from UnleashClient.events import BaseEvent, UnleashReadyEvent, UnleashFetchedEvent +from UnleashClient.events import ( + BaseEvent, + UnleashReadyEvent, + UnleashFetchedEvent, + UnleashEvent, + UnleashEventType, +) __all__ = ["UnleashProvider"] @@ -172,6 +179,44 @@ def _unleash_event_callback(self, event: BaseEvent) -> None: ), ) + def track( + self, + event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + event_details: Optional[dict] = None, + ) -> None: + """Track user actions or application states using Unleash impression events. + + Args: + event_name: The name of the tracking event + evaluation_context: Optional evaluation context + event_details: Optional tracking event details + """ + if not self.client: + return + + unleash_context = self._build_unleash_context(evaluation_context) or {} + + if event_details: + unleash_context.update( + { + "tracking_value": event_details.get("value"), + "tracking_details": event_details, + } + ) + + tracking_event = UnleashEvent( + event_type=UnleashEventType.FEATURE_FLAG, + event_id=uuid.uuid4(), + context=unleash_context, + enabled=True, + feature_name=event_name, + variant="tracking_event", + ) + + if hasattr(self, "_unleash_event_callback"): + self._unleash_event_callback(tracking_event) + def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None ) -> Optional[dict[str, Any]]: diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 5d3fd8dc..9401de97 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -68,16 +68,31 @@ def test_unleash_provider_initialization(): def test_unleash_provider_all_methods_implemented(): - """Test that all UnleashProvider methods are implemented.""" + """Test that all required methods are implemented.""" provider = UnleashProvider( url="http://localhost:4242", app_name="test-app", api_token="test-token" ) - - # All methods should be callable (not raise NotImplementedError) - assert callable(provider.resolve_string_details) - assert callable(provider.resolve_integer_details) - assert callable(provider.resolve_float_details) - assert callable(provider.resolve_object_details) + provider.initialize() + + # Test that all required methods exist + assert hasattr(provider, "get_metadata") + assert hasattr(provider, "resolve_boolean_details") + assert hasattr(provider, "resolve_string_details") + assert hasattr(provider, "resolve_integer_details") + assert hasattr(provider, "resolve_float_details") + assert hasattr(provider, "resolve_object_details") + assert hasattr(provider, "resolve_boolean_details_async") + assert hasattr(provider, "resolve_string_details_async") + assert hasattr(provider, "resolve_integer_details_async") + assert hasattr(provider, "resolve_float_details_async") + assert hasattr(provider, "resolve_object_details_async") + assert hasattr(provider, "initialize") + assert hasattr(provider, "get_status") + assert hasattr(provider, "shutdown") + assert hasattr(provider, "on_context_changed") + assert hasattr(provider, "add_handler") + assert hasattr(provider, "remove_handler") + assert hasattr(provider, "track") provider.shutdown() @@ -785,3 +800,215 @@ def test_unleash_provider_type_validation(): assert isinstance(result.value, str) provider.shutdown() + + +def test_unleash_provider_track_basic(): + """Test basic tracking functionality.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + # Set the event callback + provider._unleash_event_callback = mock_event_callback + + # Track a basic event + provider.track("user_action") + + # Verify the tracking event was created and passed to callback + assert mock_event_callback.call_count == 1 + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.feature_name == "user_action" + assert tracking_event.enabled is True + assert tracking_event.variant == "tracking_event" + + provider.shutdown() + + +def test_unleash_provider_track_with_context(): + """Test tracking with evaluation context.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "role": "admin"}, + ) + + provider.track("page_view", context) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["userId"] == "user123" + assert tracking_event.context["email"] == "user@example.com" + assert tracking_event.context["role"] == "admin" + + provider.shutdown() + + +def test_unleash_provider_track_with_event_details(): + """Test tracking with event details.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + event_details = {"value": 99.99, "currency": "USD", "category": "purchase"} + + provider.track("purchase_completed", event_details=event_details) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["tracking_value"] == 99.99 + assert tracking_event.context["tracking_details"] == event_details + + provider.shutdown() + + +def test_unleash_provider_track_not_initialized(): + """Test tracking when provider is not initialized.""" + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Should not raise any exception, just return + provider.track("test_event") + + +def test_unleash_provider_track_empty_event_name(): + """Test tracking with empty event name.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + provider.track("") + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.feature_name == "" + + provider.shutdown() + + +def test_unleash_provider_track_none_context(): + """Test tracking with None context.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + provider.track("test_event", None) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context == {} + + provider.shutdown() + + +def test_unleash_provider_track_none_event_details(): + """Test tracking with None event details.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + provider.track("test_event", event_details=None) + + tracking_event = mock_event_callback.call_args[0][0] + assert "tracking_value" not in tracking_event.context + assert "tracking_details" not in tracking_event.context + + provider.shutdown() + + +def test_unleash_provider_track_event_details_without_value(): + """Test tracking with event details that don't have a value field.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + event_details = {"category": "test", "action": "view"} + + provider.track("test_event", event_details=event_details) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["tracking_value"] is None + assert tracking_event.context["tracking_details"] == event_details + + provider.shutdown() From 8b9bc520ee1a8721b65de6a10e8182fa25195cdb Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 16:38:34 +0700 Subject: [PATCH 11/29] refactor: unleash tracker Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 47 +--- .../contrib/provider/unleash/tracking.py | 72 ++++++ .../tests/test_provider.py | 220 +----------------- .../tests/test_tracking.py | 191 +++++++++++++++ 4 files changed, 277 insertions(+), 253 deletions(-) create mode 100644 providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py create mode 100644 providers/openfeature-provider-unleash/tests/test_tracking.py diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 31c232af..5afcc74c 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,28 +1,23 @@ import json -import uuid from typing import Any, Callable, List, Mapping, Optional, Sequence, Union +from UnleashClient import UnleashClient +from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason -from openfeature.hook import Hook -from openfeature.provider import AbstractProvider, Metadata, ProviderStatus from openfeature.event import ProviderEvent from openfeature.exception import ( + ErrorCode, FlagNotFoundError, GeneralError, ParseError, TypeMismatchError, - ErrorCode, ) +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason +from openfeature.hook import Hook +from openfeature.provider import AbstractProvider, Metadata, ProviderStatus import requests -from UnleashClient import UnleashClient -from UnleashClient.events import ( - BaseEvent, - UnleashReadyEvent, - UnleashFetchedEvent, - UnleashEvent, - UnleashEventType, -) + +from .tracking import Tracker __all__ = ["UnleashProvider"] @@ -53,6 +48,7 @@ def __init__( ProviderEvent.PROVIDER_CONFIGURATION_CHANGED: [], ProviderEvent.PROVIDER_STALE: [], } + self._tracking_manager = Tracker(self) def initialize( self, evaluation_context: Optional[EvaluationContext] = None @@ -192,30 +188,7 @@ def track( evaluation_context: Optional evaluation context event_details: Optional tracking event details """ - if not self.client: - return - - unleash_context = self._build_unleash_context(evaluation_context) or {} - - if event_details: - unleash_context.update( - { - "tracking_value": event_details.get("value"), - "tracking_details": event_details, - } - ) - - tracking_event = UnleashEvent( - event_type=UnleashEventType.FEATURE_FLAG, - event_id=uuid.uuid4(), - context=unleash_context, - enabled=True, - feature_name=event_name, - variant="tracking_event", - ) - - if hasattr(self, "_unleash_event_callback"): - self._unleash_event_callback(tracking_event) + self._tracking_manager.track(event_name, evaluation_context, event_details) def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py new file mode 100644 index 00000000..1dfc5396 --- /dev/null +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py @@ -0,0 +1,72 @@ +"""Tracking functionality for Unleash provider.""" + +from typing import Any, Optional, Protocol +import uuid + +from UnleashClient.events import UnleashEvent, UnleashEventType +from openfeature.evaluation_context import EvaluationContext + + +class UnleashProvider(Protocol): + """Protocol defining the interface needed by Tracker.""" + + @property + def client(self) -> Optional[Any]: ... + + def _build_unleash_context( + self, evaluation_context: Optional[EvaluationContext] = None + ) -> Optional[dict[str, Any]]: ... + + def _unleash_event_callback(self, event: Any) -> None: ... + + +class Tracker: + """Manages tracking functionality for the Unleash provider.""" + + def __init__(self, provider: UnleashProvider) -> None: + """Initialize the tracking manager. + + Args: + provider: The parent UnleashProvider instance + """ + self._provider = provider + + def track( + self, + event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + event_details: Optional[dict] = None, + ) -> None: + """Track user actions or application states using Unleash impression events. + + Args: + event_name: The name of the tracking event + evaluation_context: Optional evaluation context + event_details: Optional tracking event details + """ + if not self._provider.client: + return + + unleash_context = ( + self._provider._build_unleash_context(evaluation_context) or {} + ) + + if event_details: + unleash_context.update( + { + "tracking_value": event_details.get("value"), + "tracking_details": event_details, + } + ) + + tracking_event = UnleashEvent( + event_type=UnleashEventType.FEATURE_FLAG, + event_id=uuid.uuid4(), + context=unleash_context, + enabled=True, + feature_name=event_name, + variant="tracking_event", + ) + + if hasattr(self._provider, "_unleash_event_callback"): + self._provider._unleash_event_callback(tracking_event) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 9401de97..b171bdf1 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -1,11 +1,7 @@ -import pytest -import requests from unittest.mock import Mock, patch from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import Reason -from openfeature.provider import ProviderStatus from openfeature.event import ProviderEvent from openfeature.exception import ( FlagNotFoundError, @@ -13,6 +9,10 @@ ParseError, TypeMismatchError, ) +from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus +import pytest +import requests def test_unleash_provider_import(): @@ -800,215 +800,3 @@ def test_unleash_provider_type_validation(): assert isinstance(result.value, str) provider.shutdown() - - -def test_unleash_provider_track_basic(): - """Test basic tracking functionality.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - # Set the event callback - provider._unleash_event_callback = mock_event_callback - - # Track a basic event - provider.track("user_action") - - # Verify the tracking event was created and passed to callback - assert mock_event_callback.call_count == 1 - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.feature_name == "user_action" - assert tracking_event.enabled is True - assert tracking_event.variant == "tracking_event" - - provider.shutdown() - - -def test_unleash_provider_track_with_context(): - """Test tracking with evaluation context.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "role": "admin"}, - ) - - provider.track("page_view", context) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["userId"] == "user123" - assert tracking_event.context["email"] == "user@example.com" - assert tracking_event.context["role"] == "admin" - - provider.shutdown() - - -def test_unleash_provider_track_with_event_details(): - """Test tracking with event details.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - event_details = {"value": 99.99, "currency": "USD", "category": "purchase"} - - provider.track("purchase_completed", event_details=event_details) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["tracking_value"] == 99.99 - assert tracking_event.context["tracking_details"] == event_details - - provider.shutdown() - - -def test_unleash_provider_track_not_initialized(): - """Test tracking when provider is not initialized.""" - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - - # Should not raise any exception, just return - provider.track("test_event") - - -def test_unleash_provider_track_empty_event_name(): - """Test tracking with empty event name.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - provider.track("") - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.feature_name == "" - - provider.shutdown() - - -def test_unleash_provider_track_none_context(): - """Test tracking with None context.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - provider.track("test_event", None) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context == {} - - provider.shutdown() - - -def test_unleash_provider_track_none_event_details(): - """Test tracking with None event details.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - provider.track("test_event", event_details=None) - - tracking_event = mock_event_callback.call_args[0][0] - assert "tracking_value" not in tracking_event.context - assert "tracking_details" not in tracking_event.context - - provider.shutdown() - - -def test_unleash_provider_track_event_details_without_value(): - """Test tracking with event details that don't have a value field.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - event_details = {"category": "test", "action": "view"} - - provider.track("test_event", event_details=event_details) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["tracking_value"] is None - assert tracking_event.context["tracking_details"] == event_details - - provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_tracking.py b/providers/openfeature-provider-unleash/tests/test_tracking.py new file mode 100644 index 00000000..6862ff40 --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/test_tracking.py @@ -0,0 +1,191 @@ +"""Tests for tracking functionality.""" + +from unittest.mock import Mock, patch + +from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext + + +def test_track_basic(): + """Test basic tracking functionality.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + # Set the event callback + provider._unleash_event_callback = mock_event_callback + + # Track a basic event + provider.track("user_action") + + # Verify the tracking event was created and passed to callback + assert mock_event_callback.call_count == 1 + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.feature_name == "user_action" + assert tracking_event.enabled is True + assert tracking_event.variant == "tracking_event" + + provider.shutdown() + + +def test_track_with_context(): + """Test tracking with evaluation context.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "role": "admin"}, + ) + + provider.track("page_view", context) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["userId"] == "user123" + assert tracking_event.context["email"] == "user@example.com" + assert tracking_event.context["role"] == "admin" + + provider.shutdown() + + +def test_track_with_event_details(): + """Test tracking with event details.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + event_details = {"value": 99.99, "currency": "USD", "category": "purchase"} + + provider.track("purchase_completed", event_details=event_details) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["tracking_value"] == 99.99 + assert tracking_event.context["tracking_details"] == event_details + + provider.shutdown() + + +def test_track_not_initialized(): + """Test tracking when provider is not initialized.""" + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Should not raise any exception, just return + provider.track("test_event") + + +def test_track_edge_cases(): + """Test tracking edge cases.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + # Test event details without value + event_details = {"category": "test", "action": "view"} + provider.track("test_event", event_details=event_details) + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context["tracking_value"] is None + assert tracking_event.context["tracking_details"] == event_details + + provider.shutdown() + + +def test_track_none_context(): + """Test tracking with None context.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + provider.track("test_event", None) + + tracking_event = mock_event_callback.call_args[0][0] + assert tracking_event.context == {} + + provider.shutdown() + + +def test_track_none_event_details(): + """Test tracking with None event details.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + mock_event_callback = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + provider._unleash_event_callback = mock_event_callback + + provider.track("test_event", event_details=None) + + tracking_event = mock_event_callback.call_args[0][0] + assert "tracking_value" not in tracking_event.context + assert "tracking_details" not in tracking_event.context + + provider.shutdown() From d935a6e46a811627424f6b0039cf3c04602abdba Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 19:00:16 +0700 Subject: [PATCH 12/29] refactor: unleash events Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 45 ++----- .../contrib/provider/unleash/events.py | 90 +++++++++++++ .../tests/test_events.py | 122 ++++++++++++++++++ .../tests/test_provider.py | 117 +---------------- 4 files changed, 223 insertions(+), 151 deletions(-) create mode 100644 providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py create mode 100644 providers/openfeature-provider-unleash/tests/test_events.py diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 5afcc74c..34b64f62 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -2,7 +2,7 @@ from typing import Any, Callable, List, Mapping, Optional, Sequence, Union from UnleashClient import UnleashClient -from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent +from UnleashClient.events import BaseEvent, UnleashReadyEvent from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent from openfeature.exception import ( @@ -17,6 +17,7 @@ from openfeature.provider import AbstractProvider, Metadata, ProviderStatus import requests +from .events import EventManager from .tracking import Tracker __all__ = ["UnleashProvider"] @@ -49,6 +50,7 @@ def __init__( ProviderEvent.PROVIDER_STALE: [], } self._tracking_manager = Tracker(self) + self._event_manager = EventManager(self) def initialize( self, evaluation_context: Optional[EvaluationContext] = None @@ -67,10 +69,10 @@ def initialize( ) self.client.initialize_client() self._status = ProviderStatus.READY - self._emit_event(ProviderEvent.PROVIDER_READY) + self._event_manager.emit_event(ProviderEvent.PROVIDER_READY) except Exception as e: self._status = ProviderStatus.ERROR - self._emit_event( + self._event_manager.emit_event( ProviderEvent.PROVIDER_ERROR, error_message=str(e), error_code=ErrorCode.GENERAL, @@ -98,7 +100,7 @@ def shutdown(self) -> None: self._status = ProviderStatus.NOT_READY except Exception as e: self._status = ProviderStatus.ERROR - self._emit_event( + self._event_manager.emit_event( ProviderEvent.PROVIDER_ERROR, error_message=str(e), error_code=ErrorCode.GENERAL, @@ -125,8 +127,7 @@ def add_handler(self, event_type: ProviderEvent, handler: Callable) -> None: event_type: The type of event to handle handler: The handler function to call """ - if event_type in self._event_handlers: - self._event_handlers[event_type].append(handler) + self._event_manager.add_handler(event_type, handler) def remove_handler(self, event_type: ProviderEvent, handler: Callable) -> None: """Remove an event handler for a specific event type. @@ -135,27 +136,7 @@ def remove_handler(self, event_type: ProviderEvent, handler: Callable) -> None: event_type: The type of event to handle handler: The handler function to remove """ - if ( - event_type in self._event_handlers - and handler in self._event_handlers[event_type] - ): - self._event_handlers[event_type].remove(handler) - - def _emit_event(self, event_type: ProviderEvent, **kwargs: Any) -> None: - """Emit an event to all registered handlers. - - Args: - event_type: The type of event to emit - **kwargs: Additional event data - """ - if event_type in self._event_handlers: - event_details = {"provider_name": self.get_metadata().name, **kwargs} - for handler in self._event_handlers[event_type]: - try: - handler(event_details) - except Exception: - # Ignore handler errors to prevent breaking other handlers - pass + self._event_manager.remove_handler(event_type, handler) def _unleash_event_callback(self, event: BaseEvent) -> None: """Callback for UnleashClient events. @@ -165,15 +146,7 @@ def _unleash_event_callback(self, event: BaseEvent) -> None: """ if isinstance(event, UnleashReadyEvent): self._status = ProviderStatus.READY - self._emit_event(ProviderEvent.PROVIDER_READY) - elif isinstance(event, UnleashFetchedEvent): - # Configuration changed when features are fetched - self._emit_event( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - flag_keys=( - list(event.features.keys()) if hasattr(event, "features") else [] - ), - ) + self._event_manager.handle_unleash_event(event) def track( self, diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py new file mode 100644 index 00000000..fdbf7029 --- /dev/null +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py @@ -0,0 +1,90 @@ +"""Events functionality for Unleash provider.""" + +from typing import Any, Callable, List, Protocol + +from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent +from openfeature.event import ProviderEvent + + +class UnleashProvider(Protocol): + """Protocol defining the interface expected from UnleashProvider for events.""" + + @property + def _event_handlers(self) -> dict[ProviderEvent, List[Callable]]: + """Event handlers dictionary.""" + ... + + def get_metadata(self) -> Any: + """Get provider metadata.""" + ... + + +class EventManager: + """Manages events and hooks for the Unleash provider.""" + + def __init__(self, provider: UnleashProvider) -> None: + """Initialize the EventManager. + + Args: + provider: The UnleashProvider instance + """ + self._provider = provider + + def add_handler(self, event_type: ProviderEvent, handler: Callable) -> None: + """Add an event handler for a specific event type. + + Args: + event_type: The type of event to handle + handler: The handler function to call + """ + if event_type in self._provider._event_handlers: + self._provider._event_handlers[event_type].append(handler) + + def remove_handler(self, event_type: ProviderEvent, handler: Callable) -> None: + """Remove an event handler for a specific event type. + + Args: + event_type: The type of event to handle + handler: The handler function to remove + """ + if ( + event_type in self._provider._event_handlers + and handler in self._provider._event_handlers[event_type] + ): + self._provider._event_handlers[event_type].remove(handler) + + def emit_event(self, event_type: ProviderEvent, **kwargs: Any) -> None: + """Emit an event to all registered handlers. + + Args: + event_type: The type of event to emit + **kwargs: Additional event data + """ + if event_type in self._provider._event_handlers: + event_details = { + "provider_name": self._provider.get_metadata().name, + **kwargs, + } + for handler in self._provider._event_handlers[event_type]: + try: + handler(event_details) + except Exception: + # Ignore handler errors to prevent breaking other handlers + pass + + def handle_unleash_event(self, event: BaseEvent) -> None: + """Handle UnleashClient events and translate them to OpenFeature events. + + Args: + event: The Unleash event + """ + if isinstance(event, UnleashReadyEvent): + self.emit_event(ProviderEvent.PROVIDER_READY) + elif isinstance(event, UnleashFetchedEvent): + # Configuration changed when features are fetched + self.emit_event( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + flag_keys=( + list(event.features.keys()) if hasattr(event, "features") else [] + ), + ) diff --git a/providers/openfeature-provider-unleash/tests/test_events.py b/providers/openfeature-provider-unleash/tests/test_events.py new file mode 100644 index 00000000..c16c4c56 --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/test_events.py @@ -0,0 +1,122 @@ +"""Tests for events functionality.""" + +from unittest.mock import Mock, patch +import uuid + +from UnleashClient.events import ( + UnleashEventType, + UnleashFetchedEvent, + UnleashReadyEvent, +) +from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.event import ProviderEvent +from openfeature.exception import GeneralError +from openfeature.provider import ProviderStatus +import pytest + + +def test_events(): + """Test that UnleashProvider supports event handling.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Test event handler registration + ready_events = [] + error_events = [] + config_events = [] + + def on_ready(event_details): + ready_events.append(event_details) + + def on_error(event_details): + error_events.append(event_details) + + def on_config_changed(event_details): + config_events.append(event_details) + + provider.add_handler(ProviderEvent.PROVIDER_READY, on_ready) + provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) + provider.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_config_changed + ) + + # Initialize should emit PROVIDER_READY + provider.initialize() + assert len(ready_events) == 1 + assert ready_events[0]["provider_name"] == "Unleash Provider" + + # Test error event emission + mock_client.initialize_client.side_effect = Exception("Test error") + error_provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + error_provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) + + with pytest.raises(GeneralError): + error_provider.initialize() + + assert len(error_events) == 1 + assert "Test error" in error_events[0]["error_message"] + + # Test handler removal + provider.remove_handler(ProviderEvent.PROVIDER_READY, on_ready) + provider.shutdown() # Should not trigger ready event again + + provider.shutdown() + + +def test_unleash_event_callback(): + """Test that UnleashProvider handles UnleashClient events correctly.""" + mock_client = Mock() + mock_client.initialize_client.return_value = None + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + + # Initialize to trigger UnleashClient creation + provider.initialize() + + # Capture the event callback + event_callback = mock_unleash_client.call_args[1]["event_callback"] + + # Test UnleashReadyEvent + ready_events = [] + provider.add_handler( + ProviderEvent.PROVIDER_READY, lambda details: ready_events.append(details) + ) + + event_callback(UnleashReadyEvent(UnleashEventType.READY, uuid.uuid4())) + assert len(ready_events) == 1 + assert provider.get_status() == ProviderStatus.READY + + # Test UnleashFetchedEvent + config_events = [] + provider.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + lambda details: config_events.append(details), + ) + + # Create a mock UnleashFetchedEvent with features + mock_event = Mock(spec=UnleashFetchedEvent) + mock_event.features = {"flag1": {}, "flag2": {}} + + event_callback(mock_event) + assert len(config_events) == 1 + assert config_events[0]["flag_keys"] == ["flag1", "flag2"] + + provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index b171bdf1..46e01da8 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -2,7 +2,6 @@ from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext -from openfeature.event import ProviderEvent from openfeature.exception import ( FlagNotFoundError, GeneralError, @@ -10,6 +9,8 @@ TypeMismatchError, ) from openfeature.flag_evaluation import Reason +from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus from openfeature.provider import ProviderStatus import pytest import requests @@ -554,120 +555,6 @@ def test_unleash_provider_flag_metadata(): provider.shutdown() -def test_unleash_provider_events(): - """Test that UnleashProvider supports event handling.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - - # Test event handler registration - ready_events = [] - error_events = [] - config_events = [] - - def on_ready(event_details): - ready_events.append(event_details) - - def on_error(event_details): - error_events.append(event_details) - - def on_config_changed(event_details): - config_events.append(event_details) - - provider.add_handler(ProviderEvent.PROVIDER_READY, on_ready) - provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) - provider.add_handler( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_config_changed - ) - - # Initialize should emit PROVIDER_READY - provider.initialize() - assert len(ready_events) == 1 - assert ready_events[0]["provider_name"] == "Unleash Provider" - - # Test error event emission - mock_client.initialize_client.side_effect = Exception("Test error") - error_provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - error_provider.add_handler(ProviderEvent.PROVIDER_ERROR, on_error) - - with pytest.raises(GeneralError): - error_provider.initialize() - - assert len(error_events) == 1 - assert "Test error" in error_events[0]["error_message"] - - # Test handler removal - provider.remove_handler(ProviderEvent.PROVIDER_READY, on_ready) - provider.shutdown() # Should not trigger ready event again - - provider.shutdown() - - -def test_unleash_provider_unleash_event_callback(): - """Test that UnleashProvider handles UnleashClient events correctly.""" - import uuid - from UnleashClient.events import ( - UnleashReadyEvent, - UnleashFetchedEvent, - UnleashEventType, - ) - - mock_client = Mock() - mock_client.initialize_client.return_value = None - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - - # Initialize to trigger UnleashClient creation - provider.initialize() - - # Capture the event callback - event_callback = mock_unleash_client.call_args[1]["event_callback"] - - # Test UnleashReadyEvent - ready_events = [] - provider.add_handler( - ProviderEvent.PROVIDER_READY, lambda details: ready_events.append(details) - ) - - event_callback(UnleashReadyEvent(UnleashEventType.READY, uuid.uuid4())) - assert len(ready_events) == 1 - assert provider.get_status() == ProviderStatus.READY - - # Test UnleashFetchedEvent - config_events = [] - provider.add_handler( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - lambda details: config_events.append(details), - ) - - # Create a mock UnleashFetchedEvent with features - mock_event = Mock(spec=UnleashFetchedEvent) - mock_event.features = {"flag1": {}, "flag2": {}} - - event_callback(mock_event) - assert len(config_events) == 1 - assert config_events[0]["flag_keys"] == ["flag1", "flag2"] - - provider.shutdown() - - def test_unleash_provider_context_without_targeting_key(): """Test that UnleashProvider works with context without targeting key.""" mock_client = Mock() From d2adda8729e39a1dc43f41baa52c6809b58a67b9 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 19:50:25 +0700 Subject: [PATCH 13/29] refactor: unleash flag evaluation Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 290 +--------- .../provider/unleash/flag_evaluation.py | 262 +++++++++ .../tests/test_flag_evaluation.py | 470 +++++++++++++++ .../tests/test_provider.py | 535 ------------------ 4 files changed, 747 insertions(+), 810 deletions(-) create mode 100644 providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py create mode 100644 providers/openfeature-provider-unleash/tests/test_flag_evaluation.py diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 34b64f62..98587928 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,23 +1,16 @@ -import json from typing import Any, Callable, List, Mapping, Optional, Sequence, Union from UnleashClient import UnleashClient from UnleashClient.events import BaseEvent, UnleashReadyEvent from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent -from openfeature.exception import ( - ErrorCode, - FlagNotFoundError, - GeneralError, - ParseError, - TypeMismatchError, -) -from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason +from openfeature.exception import ErrorCode, GeneralError +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata, ProviderStatus -import requests from .events import EventManager +from .flag_evaluation import FlagEvaluator from .tracking import Tracker __all__ = ["UnleashProvider"] @@ -51,6 +44,7 @@ def __init__( } self._tracking_manager = Tracker(self) self._event_manager = EventManager(self) + self._flag_evaluator = FlagEvaluator(self) def initialize( self, evaluation_context: Optional[EvaluationContext] = None @@ -176,90 +170,6 @@ def _build_unleash_context( context.update(evaluation_context.attributes) return context - def _resolve_variant_flag( - self, - flag_key: str, - default_value: Any, - value_converter: Callable[[Any], Any], - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[Any]: - """Helper method to resolve variant-based flags. - - Args: - flag_key: The flag key to resolve - default_value: The default value to return if flag is disabled - value_converter: Function to convert payload value to desired type - evaluation_context: Optional evaluation context - - Returns: - FlagResolutionDetails with the resolved value - """ - if not self.client: - raise GeneralError("Provider not initialized. Call initialize() first.") - - try: - # Use get_variant to get the variant payload - context = self._build_unleash_context(evaluation_context) - variant = self.client.get_variant(flag_key, context=context) - - # Check if the feature is enabled and has a payload - if variant.get("enabled", False) and "payload" in variant: - try: - payload_value = variant["payload"].get("value", default_value) - value = value_converter(payload_value) - return FlagResolutionDetails( - value=value, - reason=( - Reason.TARGETING_MATCH - if value != default_value - else Reason.DEFAULT - ), - variant=variant.get("name"), - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": variant.get("enabled", False), - "variant_name": variant.get("name") or "", - "app_name": self.app_name, - }, - ) - except (ValueError, TypeError) as e: - # If payload value can't be converted, raise TypeMismatchError - raise TypeMismatchError(str(e)) - except ParseError: - # Re-raise ParseError directly - raise - else: - return FlagResolutionDetails( - value=default_value, - reason=Reason.DEFAULT, - variant=None, - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": variant.get("enabled", False), - "variant_name": variant.get("name") or "", - "app_name": self.app_name, - }, - ) - except ( - FlagNotFoundError, - TypeMismatchError, - ParseError, - GeneralError, - ): - # Re-raise specific OpenFeature exceptions - raise - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {e}") - else: - raise GeneralError(f"HTTP error: {e}") - except Exception as e: - raise GeneralError(f"Unexpected error: {e}") - def resolve_boolean_details( self, flag_key: str, @@ -267,47 +177,9 @@ def resolve_boolean_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: """Resolve boolean flag details.""" - if not self.client: - raise GeneralError("Provider not initialized. Call initialize() first.") - - try: - - def fallback_func() -> bool: - return default_value - - context = self._build_unleash_context(evaluation_context) - value = self.client.is_enabled( - flag_key, context=context, fallback_function=fallback_func - ) - return FlagResolutionDetails( - value=value, - reason=( - Reason.TARGETING_MATCH if value != default_value else Reason.DEFAULT - ), - variant=None, - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": value, - "app_name": self.app_name, - }, - ) - except ( - FlagNotFoundError, - TypeMismatchError, - ParseError, - GeneralError, - ): - # Re-raise specific OpenFeature exceptions - raise - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {e}") - else: - raise GeneralError(f"HTTP error: {e}") - except Exception as e: - raise GeneralError(f"Unexpected error: {e}") + return self._flag_evaluator.resolve_boolean_details( + flag_key, default_value, evaluation_context + ) def resolve_string_details( self, @@ -316,8 +188,8 @@ def resolve_string_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: """Resolve string flag details.""" - return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: payload_value + return self._flag_evaluator.resolve_string_details( + flag_key, default_value, evaluation_context ) def resolve_integer_details( @@ -327,8 +199,8 @@ def resolve_integer_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: """Resolve integer flag details.""" - return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: int(payload_value) + return self._flag_evaluator.resolve_integer_details( + flag_key, default_value, evaluation_context ) def resolve_float_details( @@ -338,8 +210,8 @@ def resolve_float_details( evaluation_context: Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: """Resolve float flag details.""" - return self._resolve_variant_flag( - flag_key, default_value, lambda payload_value: float(payload_value) + return self._flag_evaluator.resolve_float_details( + flag_key, default_value, evaluation_context ) def resolve_object_details( @@ -351,138 +223,6 @@ def resolve_object_details( Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] ]: """Resolve object flag details.""" - - def object_converter(payload_value: Any) -> Union[dict, list]: - if isinstance(payload_value, str): - try: - value = json.loads(payload_value) - except json.JSONDecodeError as e: - raise ParseError(str(e)) - else: - value = payload_value - - if isinstance(value, (dict, list)): - return value - else: - raise ValueError("Payload value is not a valid object") - - return self._resolve_variant_flag(flag_key, default_value, object_converter) - - async def resolve_boolean_details_async( - self, - flag_key: str, - default_value: bool, - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[bool]: - """Resolve boolean flag details asynchronously.""" - if not self.client: - raise GeneralError("Provider not initialized. Call initialize() first.") - - try: - - def fallback_func() -> bool: - return default_value - - context = self._build_unleash_context(evaluation_context) - value = self.client.is_enabled( - flag_key, context=context, fallback_function=fallback_func - ) - return FlagResolutionDetails( - value=value, - reason=( - Reason.TARGETING_MATCH if value != default_value else Reason.DEFAULT - ), - variant=None, - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": value, - "app_name": self.app_name, - }, - ) - except ( - FlagNotFoundError, - TypeMismatchError, - ParseError, - GeneralError, - ): - # Re-raise specific OpenFeature exceptions - raise - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {e}") - else: - raise GeneralError(f"HTTP error: {e}") - except Exception as e: - raise GeneralError(f"Unexpected error: {e}") - - async def resolve_string_details_async( - self, - flag_key: str, - default_value: str, - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[str]: - """Resolve string flag details asynchronously.""" - return self._resolve_variant_flag( - flag_key, - default_value, - lambda payload_value: payload_value, - evaluation_context, - ) - - async def resolve_integer_details_async( - self, - flag_key: str, - default_value: int, - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[int]: - """Resolve integer flag details asynchronously.""" - return self._resolve_variant_flag( - flag_key, - default_value, - lambda payload_value: int(payload_value), - evaluation_context, - ) - - async def resolve_float_details_async( - self, - flag_key: str, - default_value: float, - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[float]: - """Resolve float flag details asynchronously.""" - return self._resolve_variant_flag( - flag_key, - default_value, - lambda payload_value: float(payload_value), - evaluation_context, - ) - - async def resolve_object_details_async( - self, - flag_key: str, - default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[ - Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] - ]: - """Resolve object flag details asynchronously.""" - - def object_converter(payload_value: Any) -> Union[dict, list]: - if isinstance(payload_value, str): - try: - value = json.loads(payload_value) - except json.JSONDecodeError as e: - raise ParseError(str(e)) - else: - value = payload_value - - if isinstance(value, (dict, list)): - return value - else: - raise ValueError("Payload value is not a valid object") - - return self._resolve_variant_flag( - flag_key, default_value, object_converter, evaluation_context + return self._flag_evaluator.resolve_object_details( + flag_key, default_value, evaluation_context ) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py new file mode 100644 index 00000000..b90ee57f --- /dev/null +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -0,0 +1,262 @@ +"""Flag evaluation functionality for Unleash provider.""" + +from typing import Any, Callable, Optional, Protocol + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + GeneralError, + ParseError, + TypeMismatchError, +) +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +import requests + + +class UnleashProvider(Protocol): + """Protocol defining the interface needed by FlagEvaluator.""" + + @property + def client(self) -> Optional[Any]: ... + + @property + def app_name(self) -> str: ... + + def _build_unleash_context( + self, evaluation_context: Optional[EvaluationContext] = None + ) -> Optional[dict[str, Any]]: ... + + +class FlagEvaluator: + """Manages flag evaluation functionality for the Unleash provider.""" + + def __init__(self, provider: UnleashProvider) -> None: + """Initialize the FlagEvaluator. + + Args: + provider: The parent UnleashProvider instance + """ + self._provider = provider + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + """Resolve boolean flag details. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved boolean value + """ + if not self._provider.client: + raise GeneralError("Provider not initialized. Call initialize() first.") + + try: + context = self._provider._build_unleash_context(evaluation_context) + is_enabled = self._provider.client.is_enabled(flag_key, context=context) + + return FlagResolutionDetails( + value=is_enabled, + reason=Reason.TARGETING_MATCH if is_enabled else Reason.DEFAULT, + variant=None, + error_code=None, + error_message=None, + flag_metadata={ + "source": "unleash", + "enabled": is_enabled, + "app_name": self._provider.app_name, + }, + ) + except requests.exceptions.HTTPError as e: + if e.response and e.response.status_code == 404: + raise FlagNotFoundError(f"Flag not found: {flag_key}") + raise GeneralError(f"HTTP error: {e}") + except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): + # Re-raise specific OpenFeature exceptions + raise + except Exception as e: + raise GeneralError(f"Unexpected error: {e}") + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + """Resolve string flag details. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved string value + """ + return self._resolve_variant_flag( + flag_key, default_value, str, evaluation_context + ) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + """Resolve integer flag details. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved integer value + """ + return self._resolve_variant_flag( + flag_key, default_value, int, evaluation_context + ) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + """Resolve float flag details. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved float value + """ + return self._resolve_variant_flag( + flag_key, default_value, float, evaluation_context + ) + + def resolve_object_details( + self, + flag_key: str, + default_value: Any, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Any]: + """Resolve object flag details. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved object value + """ + return self._resolve_variant_flag( + flag_key, default_value, self._parse_json, evaluation_context + ) + + def _resolve_variant_flag( + self, + flag_key: str, + default_value: Any, + value_converter: Callable[[Any], Any], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Any]: + """Helper method to resolve variant-based flags. + + Args: + flag_key: The flag key to resolve + default_value: The default value to return if flag is disabled + value_converter: Function to convert payload value to desired type + evaluation_context: Optional evaluation context + + Returns: + FlagResolutionDetails with the resolved value + """ + if not self._provider.client: + raise GeneralError("Provider not initialized. Call initialize() first.") + + try: + # Use get_variant to get the variant payload + context = self._provider._build_unleash_context(evaluation_context) + variant = self._provider.client.get_variant(flag_key, context=context) + + # Check if the feature is enabled and has a payload + if variant.get("enabled", False) and "payload" in variant: + try: + payload_value = variant["payload"].get("value", default_value) + value = value_converter(payload_value) + return FlagResolutionDetails( + value=value, + reason=( + Reason.TARGETING_MATCH + if value != default_value + else Reason.DEFAULT + ), + variant=variant.get("name"), + error_code=None, + error_message=None, + flag_metadata={ + "source": "unleash", + "enabled": variant.get("enabled", False), + "variant_name": variant.get("name") or "", + "app_name": self._provider.app_name, + }, + ) + except (ValueError, TypeError) as e: + # If payload value can't be converted, raise TypeMismatchError + raise TypeMismatchError(str(e)) + except ParseError: + # Re-raise ParseError directly + raise + else: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + variant=None, + error_code=None, + error_message=None, + flag_metadata={ + "source": "unleash", + "enabled": variant.get("enabled", False), + "app_name": self._provider.app_name, + }, + ) + except requests.exceptions.HTTPError as e: + if e.response and e.response.status_code == 404: + raise FlagNotFoundError(f"Flag not found: {flag_key}") + raise GeneralError(f"HTTP error: {e}") + except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): + # Re-raise specific OpenFeature exceptions + raise + except Exception as e: + raise GeneralError(f"Unexpected error: {e}") + + def _parse_json(self, value: Any) -> Any: + """Parse JSON value for object flags. + + Args: + value: The value to parse + + Returns: + Parsed JSON value + + Raises: + ParseError: If JSON parsing fails + """ + import json + + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as e: + raise ParseError(f"Invalid JSON: {e}") + return value diff --git a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py new file mode 100644 index 00000000..d2075f1f --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py @@ -0,0 +1,470 @@ +"""Tests for flag evaluation functionality.""" + +from unittest.mock import Mock, patch + +from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + GeneralError, + ParseError, + TypeMismatchError, +) +from openfeature.flag_evaluation import Reason +import pytest +import requests + + +def test_resolve_boolean_details(): + """Test that FlagEvaluator can resolve boolean flags.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + flag = provider.resolve_boolean_details("test_flag", False) + assert flag is not None + assert flag.value is True + assert flag.reason == Reason.TARGETING_MATCH + + +def test_resolve_boolean_details_error(): + """Test that FlagEvaluator handles errors gracefully.""" + mock_client = Mock() + mock_client.is_enabled.side_effect = requests.exceptions.ConnectionError( + "Connection error" + ) + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + with pytest.raises(GeneralError) as exc_info: + provider.resolve_boolean_details("test_flag", True) + assert "Connection error" in str(exc_info.value) + + provider.shutdown() + + +@pytest.mark.parametrize( + "method_name, payload_value, expected_value, default_value", + [ + ("resolve_string_details", "test-string", "test-string", "default"), + ("resolve_integer_details", "42", 42, 0), + ("resolve_float_details", "3.14", 3.14, 0.0), + ( + "resolve_object_details", + '{"key": "value", "number": 42}', + {"key": "value", "number": 42}, + {"default": "value"}, + ), + ], +) +def test_resolve_variant_flags( + method_name, payload_value, expected_value, default_value +): + """Test that FlagEvaluator can resolve variant-based flags.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": payload_value}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + method = getattr(provider, method_name) + flag = method("test_flag", default_value) + + assert flag.value == expected_value + assert flag.reason == Reason.TARGETING_MATCH + assert flag.variant == "test-variant" + + provider.shutdown() + + +def test_with_evaluation_context(): + """Test that FlagEvaluator uses evaluation context correctly.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + context = EvaluationContext( + targeting_key="user123", + attributes={"email": "user@example.com", "country": "US"}, + ) + + flag = provider.resolve_boolean_details("test_flag", False, context) + assert flag.value is True + + # Verify that context was passed to UnleashClient + mock_client.is_enabled.assert_called_with( + "test_flag", + context={"userId": "user123", "email": "user@example.com", "country": "US"}, + ) + + provider.shutdown() + + +@pytest.mark.parametrize( + "method_name, payload_value, default_value, expected_error_message, expected_exception", + [ + ( + "resolve_integer_details", + "not-a-number", + 0, + "invalid literal for int()", + "TypeMismatchError", + ), + ( + "resolve_object_details", + "invalid-json{", + {"default": "value"}, + "Expecting value", + "ParseError", + ), + ], +) +def test_value_conversion_errors( + method_name, + payload_value, + default_value, + expected_error_message, + expected_exception, +): + """Test that FlagEvaluator handles value conversion errors correctly.""" + mock_client = Mock() + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": payload_value}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + method = getattr(provider, method_name) + + if expected_exception == "TypeMismatchError": + with pytest.raises(TypeMismatchError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) + elif expected_exception == "ParseError": + with pytest.raises(ParseError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) + + provider.shutdown() + + +@pytest.mark.parametrize( + "method_name, mock_side_effect, default_value, expected_error_message, expected_exception", + [ + ( + "resolve_string_details", + requests.exceptions.HTTPError( + "404 Client Error: Not Found", response=Mock(status_code=404) + ), + "default", + "Flag not found", + "FlagNotFoundError", + ), + ( + "resolve_boolean_details", + requests.exceptions.ConnectionError("Connection error"), + True, + "Connection error", + "GeneralError", + ), + ], +) +def test_general_errors( + method_name, + mock_side_effect, + default_value, + expected_error_message, + expected_exception, +): + """Test that FlagEvaluator handles general errors correctly.""" + mock_client = Mock() + + if method_name == "resolve_boolean_details": + mock_client.is_enabled.side_effect = mock_side_effect + else: + mock_client.get_variant.side_effect = mock_side_effect + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + method = getattr(provider, method_name) + + if expected_exception == "FlagNotFoundError": + with pytest.raises(FlagNotFoundError) as exc_info: + method("non_existent_flag", default_value) + assert expected_error_message in str(exc_info.value) + else: + with pytest.raises(GeneralError) as exc_info: + method("test_flag", default_value) + assert expected_error_message in str(exc_info.value) + + provider.shutdown() + + +def test_edge_cases(): + """Test FlagEvaluator with edge cases and boundary conditions.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + mock_client.get_variant.return_value = { + "enabled": True, + "name": "edge-variant", + "payload": {"value": "edge-value"}, + } + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + # Test with empty string flag key + result = provider.resolve_string_details("", "default") + assert result.value == "edge-value" + + # Test with very long flag key + long_key = "a" * 1000 + result = provider.resolve_string_details(long_key, "default") + assert result.value == "edge-value" + + # Test with special characters in flag key + special_key = "flag-with-special-chars!@#$%^&*()" + result = provider.resolve_string_details(special_key, "default") + assert result.value == "edge-value" + + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "edge-value" + + provider.shutdown() + + +@pytest.mark.parametrize( + "mock_side_effect, expected_error_message", + [ + ( + requests.exceptions.HTTPError( + "429 Too Many Requests", response=Mock(status_code=429) + ), + "HTTP error", + ), + ( + requests.exceptions.Timeout("Request timeout"), + "Unexpected error", + ), + ( + requests.exceptions.SSLError("SSL certificate error"), + "Unexpected error", + ), + ], +) +def test_network_errors(mock_side_effect, expected_error_message): + """Test that FlagEvaluator handles network errors correctly.""" + mock_client = Mock() + mock_client.is_enabled.side_effect = mock_side_effect + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + with pytest.raises(GeneralError) as exc_info: + provider.resolve_boolean_details("test_flag", True) + assert expected_error_message in str(exc_info.value) + + provider.shutdown() + + +def test_context_without_targeting_key(): + """Test that FlagEvaluator works with context without targeting key.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + context = EvaluationContext(attributes={"user_id": "123", "role": "admin"}) + result = provider.resolve_boolean_details( + "test_flag", False, evaluation_context=context + ) + assert result.value is True + + provider.shutdown() + + +def test_context_with_targeting_key(): + """Test that FlagEvaluator correctly maps targeting key to userId.""" + mock_client = Mock() + mock_client.is_enabled.return_value = True + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + context = EvaluationContext( + targeting_key="user123", attributes={"role": "admin", "region": "us-east"} + ) + result = provider.resolve_boolean_details( + "test_flag", False, evaluation_context=context + ) + assert result.value is True + + mock_client.is_enabled.assert_called_once() + call_args = mock_client.is_enabled.call_args + assert call_args[1]["context"]["userId"] == "user123" + assert call_args[1]["context"]["role"] == "admin" + assert call_args[1]["context"]["region"] == "us-east" + + provider.shutdown() + + +def test_variant_flag_scenarios(): + """Test various variant flag scenarios.""" + mock_client = Mock() + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + mock_client.get_variant.return_value = { + "enabled": False, + "name": "disabled-variant", + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant is None + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "enabled-variant", + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant is None + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {"value": "variant-value"}, + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "variant-value" + assert result.reason == Reason.TARGETING_MATCH + assert result.variant == "test-variant" + + mock_client.get_variant.return_value = { + "enabled": True, + "name": "test-variant", + "payload": {}, + } + result = provider.resolve_string_details("test_flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.variant == "test-variant" + + provider.shutdown() + + +def test_type_validation(): + """Test that FlagEvaluator handles type validation correctly.""" + mock_client = Mock() + # Mock returning wrong type for boolean flag + mock_client.is_enabled.return_value = "not-a-boolean" + + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + result = provider.resolve_boolean_details("test_flag", False) + assert result.value == "not-a-boolean" + assert isinstance(result.value, str) + + provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 46e01da8..8dcc77af 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -2,18 +2,7 @@ from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ( - FlagNotFoundError, - GeneralError, - ParseError, - TypeMismatchError, -) -from openfeature.flag_evaluation import Reason -from openfeature.flag_evaluation import Reason from openfeature.provider import ProviderStatus -from openfeature.provider import ProviderStatus -import pytest -import requests def test_unleash_provider_import(): @@ -82,11 +71,6 @@ def test_unleash_provider_all_methods_implemented(): assert hasattr(provider, "resolve_integer_details") assert hasattr(provider, "resolve_float_details") assert hasattr(provider, "resolve_object_details") - assert hasattr(provider, "resolve_boolean_details_async") - assert hasattr(provider, "resolve_string_details_async") - assert hasattr(provider, "resolve_integer_details_async") - assert hasattr(provider, "resolve_float_details_async") - assert hasattr(provider, "resolve_object_details_async") assert hasattr(provider, "initialize") assert hasattr(provider, "get_status") assert hasattr(provider, "shutdown") @@ -108,391 +92,6 @@ def test_unleash_provider_hooks(): provider.shutdown() -def test_unleash_provider_resolve_boolean_details(unleash_provider_client): - """Test that UnleashProvider can resolve boolean flags.""" - client = unleash_provider_client - - flag = client.get_boolean_details(flag_key="test_flag", default_value=False) - assert flag is not None - assert flag.value is True - assert flag.reason == Reason.TARGETING_MATCH - - -def test_unleash_provider_resolve_boolean_details_error(): - """Test that UnleashProvider handles errors gracefully.""" - mock_client = Mock() - mock_client.is_enabled.side_effect = requests.exceptions.ConnectionError( - "Connection error" - ) - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - with pytest.raises(GeneralError) as exc_info: - provider.resolve_boolean_details("test_flag", True) - assert "Connection error" in str(exc_info.value) - - provider.shutdown() - - -@pytest.mark.parametrize( - "method_name, payload_value, expected_value, default_value", - [ - ("resolve_string_details", "test-string", "test-string", "default"), - ("resolve_integer_details", "42", 42, 0), - ("resolve_float_details", "3.14", 3.14, 0.0), - ( - "resolve_object_details", - '{"key": "value", "number": 42}', - {"key": "value", "number": 42}, - {"default": "value"}, - ), - ], -) -def test_unleash_provider_resolve_variant_flags( - method_name, payload_value, expected_value, default_value -): - """Test that UnleashProvider can resolve variant-based flags.""" - mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": payload_value}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - method = getattr(provider, method_name) - flag = method("test_flag", default_value) - - assert flag.value == expected_value - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" - - provider.shutdown() - - -@pytest.mark.asyncio -async def test_unleash_provider_resolve_boolean_details_async(): - """Test that UnleashProvider can resolve boolean flags asynchronously.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - flag = await provider.resolve_boolean_details_async("test_flag", False) - assert flag.value is True - assert flag.reason == Reason.TARGETING_MATCH - - provider.shutdown() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "method_name, payload_value, expected_value, default_value", - [ - ( - "resolve_string_details_async", - "test-string", - "test-string", - "default", - ), - ("resolve_integer_details_async", "42", 42, 0), - ("resolve_float_details_async", "3.14", 3.14, 0.0), - ( - "resolve_object_details_async", - '{"key": "value", "number": 42}', - {"key": "value", "number": 42}, - {"default": "value"}, - ), - ], -) -async def test_unleash_provider_resolve_variant_flags_async( - method_name, payload_value, expected_value, default_value -): - """Test that UnleashProvider can resolve variant-based flags asynchronously.""" - mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": payload_value}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - method = getattr(provider, method_name) - flag = await method("test_flag", default_value) - - assert flag.value == expected_value - assert flag.reason == Reason.TARGETING_MATCH - assert flag.variant == "test-variant" - - provider.shutdown() - - -def test_unleash_provider_with_evaluation_context(): - """Test that UnleashProvider uses evaluation context correctly.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "country": "US"}, - ) - - flag = provider.resolve_boolean_details("test_flag", False, context) - assert flag.value is True - - # Verify that context was passed to UnleashClient - mock_client.is_enabled.assert_called_with( - "test_flag", - context={"userId": "user123", "email": "user@example.com", "country": "US"}, - fallback_function=mock_client.is_enabled.call_args[1]["fallback_function"], - ) - - provider.shutdown() - - -@pytest.mark.parametrize( - "method_name, payload_value, default_value, expected_error_message, expected_exception", - [ - ( - "resolve_integer_details", - "not-a-number", - 0, - "invalid literal for int()", - "TypeMismatchError", - ), - ( - "resolve_object_details", - "invalid-json{", - {"default": "value"}, - "Expecting value", - "ParseError", - ), - ], -) -def test_unleash_provider_value_conversion_errors( - method_name, - payload_value, - default_value, - expected_error_message, - expected_exception, -): - """Test that UnleashProvider handles value conversion errors correctly.""" - mock_client = Mock() - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": payload_value}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - method = getattr(provider, method_name) - - if expected_exception == "TypeMismatchError": - with pytest.raises(TypeMismatchError) as exc_info: - method("test_flag", default_value) - assert expected_error_message in str(exc_info.value) - elif expected_exception == "ParseError": - with pytest.raises(ParseError) as exc_info: - method("test_flag", default_value) - assert expected_error_message in str(exc_info.value) - - provider.shutdown() - - -@pytest.mark.parametrize( - "method_name, mock_side_effect, default_value, expected_error_message, expected_exception", - [ - ( - "resolve_string_details", - requests.exceptions.HTTPError( - "404 Client Error: Not Found", response=Mock(status_code=404) - ), - "default", - "Flag not found", - "FlagNotFoundError", - ), - ( - "resolve_boolean_details", - requests.exceptions.ConnectionError("Connection error"), - True, - "Connection error", - "GeneralError", - ), - ], -) -def test_unleash_provider_general_errors( - method_name, - mock_side_effect, - default_value, - expected_error_message, - expected_exception, -): - """Test that UnleashProvider handles general errors correctly.""" - mock_client = Mock() - - if method_name == "resolve_boolean_details": - mock_client.is_enabled.side_effect = mock_side_effect - else: - mock_client.get_variant.side_effect = mock_side_effect - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - method = getattr(provider, method_name) - - if expected_exception == "FlagNotFoundError": - with pytest.raises(FlagNotFoundError) as exc_info: - method("non_existent_flag", default_value) - assert expected_error_message in str(exc_info.value) - else: - with pytest.raises(GeneralError) as exc_info: - method("test_flag", default_value) - assert expected_error_message in str(exc_info.value) - - provider.shutdown() - - -def test_unleash_provider_edge_cases(): - """Test UnleashProvider with edge cases and boundary conditions.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - mock_client.get_variant.return_value = { - "enabled": True, - "name": "edge-variant", - "payload": {"value": "edge-value"}, - } - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - # Test with empty string flag key - result = provider.resolve_string_details("", "default") - assert result.value == "edge-value" - - # Test with very long flag key - long_key = "a" * 1000 - result = provider.resolve_string_details(long_key, "default") - assert result.value == "edge-value" - - # Test with special characters in flag key - special_key = "flag-with-special-chars!@#$%^&*()" - result = provider.resolve_string_details(special_key, "default") - assert result.value == "edge-value" - - result = provider.resolve_string_details("test_flag", "default") - assert result.value == "edge-value" - - provider.shutdown() - - -@pytest.mark.parametrize( - "mock_side_effect, expected_error_message", - [ - ( - requests.exceptions.HTTPError( - "429 Too Many Requests", response=Mock(status_code=429) - ), - "HTTP error", - ), - ( - requests.exceptions.Timeout("Request timeout"), - "Unexpected error", - ), - ( - requests.exceptions.SSLError("SSL certificate error"), - "Unexpected error", - ), - ], -) -def test_unleash_provider_network_errors(mock_side_effect, expected_error_message): - """Test that UnleashProvider handles network errors correctly.""" - mock_client = Mock() - mock_client.is_enabled.side_effect = mock_side_effect - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - with pytest.raises(GeneralError) as exc_info: - provider.resolve_boolean_details("test_flag", True) - assert expected_error_message in str(exc_info.value) - - provider.shutdown() - - def test_unleash_provider_context_changed(): """Test that UnleashProvider handles context changes correctly.""" provider = UnleashProvider( @@ -553,137 +152,3 @@ def test_unleash_provider_flag_metadata(): assert result.flag_metadata["app_name"] == "test-app" provider.shutdown() - - -def test_unleash_provider_context_without_targeting_key(): - """Test that UnleashProvider works with context without targeting key.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - context = EvaluationContext(attributes={"user_id": "123", "role": "admin"}) - result = provider.resolve_boolean_details( - "test_flag", False, evaluation_context=context - ) - assert result.value is True - - provider.shutdown() - - -def test_unleash_provider_context_with_targeting_key(): - """Test that UnleashProvider correctly maps targeting key to userId.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - context = EvaluationContext( - targeting_key="user123", attributes={"role": "admin", "region": "us-east"} - ) - result = provider.resolve_boolean_details( - "test_flag", False, evaluation_context=context - ) - assert result.value is True - - mock_client.is_enabled.assert_called_once() - call_args = mock_client.is_enabled.call_args - assert call_args[1]["context"]["userId"] == "user123" - assert call_args[1]["context"]["role"] == "admin" - assert call_args[1]["context"]["region"] == "us-east" - - provider.shutdown() - - -def test_unleash_provider_variant_flag_scenarios(): - """Test various variant flag scenarios.""" - mock_client = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - mock_client.get_variant.return_value = { - "enabled": False, - "name": "disabled-variant", - } - result = provider.resolve_string_details("test_flag", "default") - assert result.value == "default" - assert result.reason == Reason.DEFAULT - assert result.variant is None - - mock_client.get_variant.return_value = { - "enabled": True, - "name": "enabled-variant", - } - result = provider.resolve_string_details("test_flag", "default") - assert result.value == "default" - assert result.reason == Reason.DEFAULT - assert result.variant is None - - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {"value": "variant-value"}, - } - result = provider.resolve_string_details("test_flag", "default") - assert result.value == "variant-value" - assert result.reason == Reason.TARGETING_MATCH - assert result.variant == "test-variant" - - mock_client.get_variant.return_value = { - "enabled": True, - "name": "test-variant", - "payload": {}, - } - result = provider.resolve_string_details("test_flag", "default") - assert result.value == "default" - assert result.reason == Reason.DEFAULT - assert result.variant == "test-variant" - - provider.shutdown() - - -def test_unleash_provider_type_validation(): - """Test that UnleashProvider handles type validation correctly.""" - mock_client = Mock() - # Mock returning wrong type for boolean flag - mock_client.is_enabled.return_value = "not-a-boolean" - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - result = provider.resolve_boolean_details("test_flag", False) - assert result.value == "not-a-boolean" - assert isinstance(result.value, str) - - provider.shutdown() From 673eee41b2dc48efb3ca1001bf29dd9188cb9147 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 23:27:12 +0700 Subject: [PATCH 14/29] feat(provider): unleash specify environment when initializing client Signed-off-by: Kiki L Hakiem --- .../src/openfeature/contrib/provider/unleash/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 98587928..ffaaa10d 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -22,6 +22,7 @@ def __init__( url: str, app_name: str, api_token: str, + environment: str = "development", ) -> None: """Initialize the Unleash provider. @@ -29,10 +30,12 @@ def __init__( url: The Unleash API URL app_name: The application name api_token: The API token for authentication + environment: The environment to connect to (default: "development") """ self.url = url self.app_name = app_name self.api_token = api_token + self.environment = environment self.client: Optional[UnleashClient] = None self._status = ProviderStatus.NOT_READY self._last_context: Optional[EvaluationContext] = None @@ -58,6 +61,7 @@ def initialize( self.client = UnleashClient( url=self.url, app_name=self.app_name, + environment=self.environment, custom_headers={"Authorization": self.api_token}, event_callback=self._unleash_event_callback, ) From 9387714045dfbe174ebf874e8dcf1d5d3193a86b Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 27 Aug 2025 23:28:09 +0700 Subject: [PATCH 15/29] fix(provider): handle_unleash_event Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/events.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py index fdbf7029..5d9ca511 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py @@ -82,9 +82,18 @@ def handle_unleash_event(self, event: BaseEvent) -> None: self.emit_event(ProviderEvent.PROVIDER_READY) elif isinstance(event, UnleashFetchedEvent): # Configuration changed when features are fetched + flag_keys = [] + if hasattr(event, "features"): + if isinstance(event.features, dict): + flag_keys = list(event.features.keys()) + elif isinstance(event.features, list): + flag_keys = [ + feature.get("name", "") + for feature in event.features + if isinstance(feature, dict) + ] + self.emit_event( ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - flag_keys=( - list(event.features.keys()) if hasattr(event, "features") else [] - ), + flag_keys=flag_keys, ) From 13415b684b891e4758d81e83fd18d823b7061ab3 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 28 Aug 2025 00:59:26 +0700 Subject: [PATCH 16/29] test(provider): add unleash integration tests Signed-off-by: Kiki L Hakiem --- .../pyproject.toml | 5 + .../tests/test_integration.py | 328 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 providers/openfeature-provider-unleash/tests/test_integration.py diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index e0b40bb7..9a7b4905 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -62,6 +62,11 @@ pretty = true strict = true disallow_any_generics = false +[tool.pytest.ini_options] +markers = [ + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", +] + [project.scripts] # workaround while UV doesn't support scripts directly in the pyproject.toml # see: https://github.com/astral-sh/uv/issues/5903 diff --git a/providers/openfeature-provider-unleash/tests/test_integration.py b/providers/openfeature-provider-unleash/tests/test_integration.py new file mode 100644 index 00000000..173a4022 --- /dev/null +++ b/providers/openfeature-provider-unleash/tests/test_integration.py @@ -0,0 +1,328 @@ +"""Integration tests for Unleash provider using a running Unleash instance.""" + +from openfeature import api +from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext +import pytest +import requests + + +UNLEASH_URL = "http://0.0.0.0:4242/api" +API_TOKEN = "default:development.unleash-insecure-api-token" +ADMIN_TOKEN = "user:76672ac99726f8e48a1bbba16b7094a50d1eee3583d1e8457e12187a" + + +def create_test_flags(): + """Create test flags in the Unleash instance.""" + flags = [ + { + "name": "integration-boolean-flag", + "description": "Boolean feature flag for testing", + "type": "release", + "enabled": True, + }, + { + "name": "integration-string-flag", + "description": "String feature flag for testing", + "type": "release", + "enabled": True, + }, + { + "name": "integration-float-flag", + "description": "Float feature flag for testing", + "type": "release", + "enabled": True, + }, + { + "name": "integration-object-flag", + "description": "Object feature flag for testing", + "type": "release", + "enabled": True, + }, + { + "name": "integration-integer-flag", + "description": "Integer feature flag for testing", + "type": "release", + "enabled": True, + }, + { + "name": "integration-targeting-flag", + "description": "Targeting feature flag for testing", + "type": "release", + "enabled": True, + }, + ] + + headers = {"Authorization": ADMIN_TOKEN, "Content-Type": "application/json"} + + for flag in flags: + try: + response = requests.post( + f"{UNLEASH_URL}/admin/projects/default/features", + headers=headers, + json=flag, + timeout=10, + ) + if response.status_code in [200, 201, 409]: + print(f"Flag '{flag['name']}' created") + add_strategy_with_variants(flag["name"], headers) + enable_flag(flag["name"], headers) + else: + print(f"Failed to create flag '{flag['name']}': {response.status_code}") + except Exception as e: + print(f"Error creating flag '{flag['name']}': {e}") + + +def add_strategy_with_variants(flag_name: str, headers: dict): + """Add strategy with variants to a flag.""" + variant_configs = { + "integration-boolean-flag": [ + { + "stickiness": "default", + "name": "true", + "weight": 1000, + "payload": {"type": "string", "value": "true"}, + "weightType": "variable", + } + ], + "integration-string-flag": [ + { + "stickiness": "default", + "name": "my-string", + "weight": 1000, + "payload": {"type": "string", "value": "my-string"}, + "weightType": "variable", + } + ], + "integration-float-flag": [ + { + "stickiness": "default", + "name": "9000.5", + "weight": 1000, + "payload": {"type": "string", "value": "9000.5"}, + "weightType": "variable", + } + ], + "integration-object-flag": [ + { + "stickiness": "default", + "name": "object-variant", + "weight": 1000, + "payload": {"type": "json", "value": '{"foo": "bar"}'}, + "weightType": "variable", + } + ], + "integration-integer-flag": [ + { + "stickiness": "default", + "name": "42", + "weight": 1000, + "payload": {"type": "string", "value": "42"}, + "weightType": "variable", + } + ], + "integration-targeting-flag": [ + { + "stickiness": "default", + "name": "targeted-true", + "weight": 1000, + "payload": {"type": "string", "value": "true"}, + "weightType": "variable", + } + ], + } + + # Add targeting constraints for the targeting flag + constraints = [] + if flag_name == "integration-targeting-flag": + constraints = [ + { + "contextName": "userId", + "operator": "IN", + "values": ["targeted-user"], + } + ] + + strategy_payload = { + "name": "flexibleRollout", + "constraints": constraints, + "parameters": { + "rollout": "100", + "stickiness": "default", + "groupId": flag_name, + }, + "variants": variant_configs.get(flag_name, []), + "segments": [], + "disabled": False, + } + + strategy_response = requests.post( + f"{UNLEASH_URL}/admin/projects/default/features/{flag_name}/environments/development/strategies", + headers=headers, + json=strategy_payload, + timeout=10, + ) + if strategy_response.status_code in [200, 201]: + print(f"Strategy with variants added to '{flag_name}'") + else: + print( + f"Failed to add strategy to '{flag_name}': {strategy_response.status_code}" + ) + + +def enable_flag(flag_name: str, headers: dict): + """Enable a flag in the development environment.""" + try: + enable_response = requests.post( + f"{UNLEASH_URL}/admin/projects/default/features/{flag_name}/environments/development/on", + headers=headers, + timeout=10, + ) + if enable_response.status_code in [200, 201]: + print(f"Flag '{flag_name}' enabled in development environment") + else: + print(f"Failed to enable flag '{flag_name}': {enable_response.status_code}") + except Exception as e: + print(f"Error enabling flag '{flag_name}': {e}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_flags(): + """Setup test flags before running any tests.""" + print("Creating test flags in Unleash...") + create_test_flags() + print("Test flags setup completed") + + +@pytest.fixture +def unleash_provider(): + """Create an Unleash provider instance for testing.""" + provider = UnleashProvider( + url=UNLEASH_URL, + app_name="test-app", + api_token=API_TOKEN, + ) + provider.initialize() + yield provider + # Clean up the provider to avoid multiple UnleashClient instances + provider.shutdown() + + +@pytest.fixture +def client(unleash_provider): + """Create an OpenFeature client with the Unleash provider.""" + # Set the provider globally + api.set_provider(unleash_provider) + return api.get_client() + + +@pytest.mark.integration +def test_integration_health_check(): + """Test that Unleash health check endpoint is accessible.""" + response = requests.get(f"{UNLEASH_URL.replace('/api', '')}/health", timeout=5) + assert response.status_code == 200 + + +@pytest.mark.integration +def test_integration_provider_initialization(unleash_provider): + """Test that the Unleash provider can be initialized.""" + assert unleash_provider is not None + assert unleash_provider.client is not None + + +@pytest.mark.integration +def test_integration_provider_metadata(unleash_provider): + """Test that the provider returns correct metadata.""" + metadata = unleash_provider.get_metadata() + assert metadata.name == "Unleash Provider" + + +@pytest.mark.integration +def test_integration_flag_details_resolution(unleash_provider): + """Test flag details resolution with the Unleash provider.""" + details = unleash_provider.resolve_boolean_details( + "integration-boolean-flag", False + ) + assert details is not None + assert hasattr(details, "value") + assert hasattr(details, "reason") + assert hasattr(details, "variant") + assert details.value is True + + +@pytest.mark.integration +def test_integration_provider_status(unleash_provider): + """Test that the provider status is correctly reported.""" + status = unleash_provider.get_status() + assert status.value == "READY" + + +@pytest.mark.integration +def test_integration_boolean_flag_resolution(unleash_provider): + """Test boolean flag resolution with the Unleash provider.""" + details = unleash_provider.resolve_boolean_details( + "integration-boolean-flag", False + ) + assert details.value is True + + +@pytest.mark.integration +def test_integration_string_flag_resolution(unleash_provider): + """Test string flag resolution with the Unleash provider.""" + details = unleash_provider.resolve_string_details( + "integration-string-flag", "default" + ) + assert details.value == "my-string" + + +@pytest.mark.integration +def test_integration_integer_flag_resolution(unleash_provider): + """Test integer flag resolution with the Unleash provider.""" + details = unleash_provider.resolve_integer_details("integration-integer-flag", 0) + assert details.value == 42 + + +@pytest.mark.integration +def test_integration_float_flag_resolution(unleash_provider): + """Test float flag resolution with the Unleash provider.""" + details = unleash_provider.resolve_float_details("integration-float-flag", 0.0) + assert details.value == 9000.5 + + +@pytest.mark.integration +def test_integration_object_flag_resolution(unleash_provider): + """Test object flag resolution with the Unleash provider.""" + details = unleash_provider.resolve_object_details("integration-object-flag", {}) + assert details.value == {"foo": "bar"} + + +@pytest.mark.integration +def test_integration_nonexistent_flag(unleash_provider): + """Test that non-existent flags return default value.""" + details = unleash_provider.resolve_boolean_details("test-nonexistent-flag", False) + assert details.value is False + assert details.reason.value == "DEFAULT" + + +@pytest.mark.integration +def test_integration_targeting_positive_case(unleash_provider): + """Test targeting with a user that should be targeted (positive case).""" + context = EvaluationContext(targeting_key="targeted-user") + + details = unleash_provider.resolve_boolean_details( + "integration-targeting-flag", False, context + ) + assert details.value is True + assert isinstance(details.value, bool) + + +@pytest.mark.integration +def test_integration_targeting_negative_case(unleash_provider): + """Test targeting with a user that should not be targeted (negative case).""" + context = EvaluationContext(targeting_key="non-targeted-user") + + details = unleash_provider.resolve_boolean_details( + "integration-targeting-flag", False, context + ) + assert details.value is False + assert isinstance(details.value, bool) From 5f1b665b4c6295ce7436f6de7cba76f7e868f403 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 28 Aug 2025 16:30:57 +0700 Subject: [PATCH 17/29] test(provider): unleash integration test use testcontainers Signed-off-by: Kiki L Hakiem --- .../pyproject.toml | 2 + .../tests/conftest.py | 25 --- .../tests/test_integration.py | 182 ++++++++++++++---- .../tests/test_provider.py | 48 +++-- uv.lock | 70 +++++++ 5 files changed, 249 insertions(+), 78 deletions(-) delete mode 100644 providers/openfeature-provider-unleash/tests/conftest.py diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index 9a7b4905..d0d0a301 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -31,6 +31,8 @@ dev = [ "mypy[faster-cache]>=1.17.0,<2.0.0", "pytest>=8.4.0,<9.0.0", "pytest-asyncio>=0.23.0", + "psycopg2-binary>=2.9.0,<3.0.0", + "testcontainers>=4.12.0,<5.0.0", "types-requests>=2.31.0", ] diff --git a/providers/openfeature-provider-unleash/tests/conftest.py b/providers/openfeature-provider-unleash/tests/conftest.py deleted file mode 100644 index 22169c75..00000000 --- a/providers/openfeature-provider-unleash/tests/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from unittest.mock import Mock, patch - -from openfeature import api -from openfeature.contrib.provider.unleash import UnleashProvider - - -@pytest.fixture() -def unleash_provider_client(): - """Create Unleash provider with test client using mocked UnleashClient.""" - mock_client = Mock() - mock_client.is_enabled.return_value = True - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - api.set_provider(provider) - yield api.get_client() - provider.shutdown() diff --git a/providers/openfeature-provider-unleash/tests/test_integration.py b/providers/openfeature-provider-unleash/tests/test_integration.py index 173a4022..47567354 100644 --- a/providers/openfeature-provider-unleash/tests/test_integration.py +++ b/providers/openfeature-provider-unleash/tests/test_integration.py @@ -1,17 +1,78 @@ -"""Integration tests for Unleash provider using a running Unleash instance.""" +"""Integration tests for Unleash provider using testcontainers.""" + +from datetime import datetime, timezone +import time from openfeature import api from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext +import psycopg2 import pytest import requests +from testcontainers.core.container import DockerContainer +from testcontainers.postgres import PostgresContainer - -UNLEASH_URL = "http://0.0.0.0:4242/api" +# Configuration for the running Unleash instance (will be set by fixtures) +UNLEASH_URL = None API_TOKEN = "default:development.unleash-insecure-api-token" ADMIN_TOKEN = "user:76672ac99726f8e48a1bbba16b7094a50d1eee3583d1e8457e12187a" +class UnleashContainer(DockerContainer): + """Custom Unleash container with health check.""" + + def __init__(self, postgres_url: str, **kwargs): + super().__init__("unleashorg/unleash-server:latest", **kwargs) + self.postgres_url = postgres_url + + def _configure(self): + self.with_env("DATABASE_URL", self.postgres_url) + self.with_env("DATABASE_URL_FILE", "") + self.with_env("DATABASE_SSL", "false") + self.with_env("DATABASE_SSL_REJECT_UNAUTHORIZED", "false") + self.with_env("LOG_LEVEL", "info") + self.with_env("PORT", "4242") + self.with_env("HOST", "0.0.0.0") + self.with_env("ADMIN_AUTHENTICATION", "none") + self.with_env("AUTH_ENABLE", "false") + self.with_env("INIT_CLIENT_API_TOKENS", API_TOKEN) + # Expose the Unleash port + self.with_exposed_ports(4242) + + +def insert_admin_token(postgres_container): + """Insert admin token into the Unleash database.""" + url = postgres_container.get_connection_url() + conn = psycopg2.connect(url) + + try: + with conn.cursor() as cursor: + cursor.execute( + """ + INSERT INTO "public"."personal_access_tokens" + ("secret", "description", "user_id", "expires_at", "seen_at", "created_at", "id") + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO NOTHING + """, + ( + "user:76672ac99726f8e48a1bbba16b7094a50d1eee3583d1e8457e12187a", + "my-token", + 1, + datetime(3025, 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + datetime.now(timezone.utc), + datetime.now(timezone.utc), + 1, + ), + ) + conn.commit() + print("Admin token inserted successfully") + except Exception as e: + print(f"Error inserting admin token: {e}") + conn.rollback() + finally: + conn.close() + + def create_test_flags(): """Create test flags in the Unleash instance.""" flags = [ @@ -58,7 +119,7 @@ def create_test_flags(): for flag in flags: try: response = requests.post( - f"{UNLEASH_URL}/admin/projects/default/features", + f"{UNLEASH_URL}/api/admin/projects/default/features", headers=headers, json=flag, timeout=10, @@ -157,7 +218,7 @@ def add_strategy_with_variants(flag_name: str, headers: dict): } strategy_response = requests.post( - f"{UNLEASH_URL}/admin/projects/default/features/{flag_name}/environments/development/strategies", + f"{UNLEASH_URL}/api/admin/projects/default/features/{flag_name}/environments/development/strategies", headers=headers, json=strategy_payload, timeout=10, @@ -174,7 +235,7 @@ def enable_flag(flag_name: str, headers: dict): """Enable a flag in the development environment.""" try: enable_response = requests.post( - f"{UNLEASH_URL}/admin/projects/default/features/{flag_name}/environments/development/on", + f"{UNLEASH_URL}/api/admin/projects/default/features/{flag_name}/environments/development/on", headers=headers, timeout=10, ) @@ -186,19 +247,95 @@ def enable_flag(flag_name: str, headers: dict): print(f"Error enabling flag '{flag_name}': {e}") +@pytest.fixture(scope="session") +def postgres_container(): + """Create and start PostgreSQL container.""" + with PostgresContainer("postgres:15", driver=None) as postgres: + postgres.start() + postgres_url = postgres.get_connection_url() + print(f"PostgreSQL started at: {postgres_url}") + + yield postgres + + +@pytest.fixture(scope="session") +def unleash_container(postgres_container): + """Create and start Unleash container with PostgreSQL dependency.""" + global UNLEASH_URL + + postgres_url = postgres_container.get_connection_url() + postgres_bridge_ip = postgres_container.get_docker_client().bridge_ip( + postgres_container._container.id + ) + + # Create internal URL using the bridge IP and internal port (5432) + exposed_port = postgres_container.get_exposed_port(5432) + internal_url = postgres_url.replace("localhost", postgres_bridge_ip).replace( + f":{exposed_port}", ":5432" + ) + + unleash = UnleashContainer(internal_url) + + with unleash as container: + print("Starting Unleash container...") + container.start() + print("Unleash container started") + + # Wait for health check to pass + print("Waiting for Unleash container to be healthy...") + max_wait_time = 60 # 1 minute max wait + start_time = time.time() + + while time.time() - start_time < max_wait_time: + try: + # Get the exposed port + try: + exposed_port = container.get_exposed_port(4242) + unleash_url = f"http://localhost:{exposed_port}" + print(f"Trying health check at: {unleash_url}") + except Exception as port_error: + print(f"Port not ready yet: {port_error}") + time.sleep(2) + continue + + # Try to connect to health endpoint + response = requests.get(f"{unleash_url}/health", timeout=5) + if response.status_code == 200: + print("Unleash container is healthy!") + break + + print(f"Health check failed, status: {response.status_code}") + time.sleep(2) + + except Exception as e: + print(f"Health check error: {e}") + time.sleep(2) + else: + raise Exception("Unleash container did not become healthy within timeout") + + # Get the exposed port and set global URL + UNLEASH_URL = f"http://localhost:{container.get_exposed_port(4242)}" + print(f"Unleash started at: {unleash_url}") + + insert_admin_token(postgres_container) + print("Admin token inserted into database") + + yield container, unleash_url + + @pytest.fixture(scope="session", autouse=True) -def setup_test_flags(): +def setup_test_flags(unleash_container): """Setup test flags before running any tests.""" print("Creating test flags in Unleash...") create_test_flags() print("Test flags setup completed") -@pytest.fixture -def unleash_provider(): +@pytest.fixture(scope="session") +def unleash_provider(setup_test_flags): """Create an Unleash provider instance for testing.""" provider = UnleashProvider( - url=UNLEASH_URL, + url=f"{UNLEASH_URL}/api", app_name="test-app", api_token=API_TOKEN, ) @@ -208,7 +345,7 @@ def unleash_provider(): provider.shutdown() -@pytest.fixture +@pytest.fixture(scope="session") def client(unleash_provider): """Create an OpenFeature client with the Unleash provider.""" # Set the provider globally @@ -219,24 +356,10 @@ def client(unleash_provider): @pytest.mark.integration def test_integration_health_check(): """Test that Unleash health check endpoint is accessible.""" - response = requests.get(f"{UNLEASH_URL.replace('/api', '')}/health", timeout=5) + response = requests.get(f"{UNLEASH_URL}/health", timeout=5) assert response.status_code == 200 -@pytest.mark.integration -def test_integration_provider_initialization(unleash_provider): - """Test that the Unleash provider can be initialized.""" - assert unleash_provider is not None - assert unleash_provider.client is not None - - -@pytest.mark.integration -def test_integration_provider_metadata(unleash_provider): - """Test that the provider returns correct metadata.""" - metadata = unleash_provider.get_metadata() - assert metadata.name == "Unleash Provider" - - @pytest.mark.integration def test_integration_flag_details_resolution(unleash_provider): """Test flag details resolution with the Unleash provider.""" @@ -250,13 +373,6 @@ def test_integration_flag_details_resolution(unleash_provider): assert details.value is True -@pytest.mark.integration -def test_integration_provider_status(unleash_provider): - """Test that the provider status is correctly reported.""" - status = unleash_provider.get_status() - assert status.value == "READY" - - @pytest.mark.integration def test_integration_boolean_flag_resolution(unleash_provider): """Test boolean flag resolution with the Unleash provider.""" diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 8dcc77af..ae8e4b40 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -59,27 +59,35 @@ def test_unleash_provider_initialization(): def test_unleash_provider_all_methods_implemented(): """Test that all required methods are implemented.""" - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - # Test that all required methods exist - assert hasattr(provider, "get_metadata") - assert hasattr(provider, "resolve_boolean_details") - assert hasattr(provider, "resolve_string_details") - assert hasattr(provider, "resolve_integer_details") - assert hasattr(provider, "resolve_float_details") - assert hasattr(provider, "resolve_object_details") - assert hasattr(provider, "initialize") - assert hasattr(provider, "get_status") - assert hasattr(provider, "shutdown") - assert hasattr(provider, "on_context_changed") - assert hasattr(provider, "add_handler") - assert hasattr(provider, "remove_handler") - assert hasattr(provider, "track") + mock_client = Mock() + mock_client.initialize_client.return_value = None - provider.shutdown() + with patch( + "openfeature.contrib.provider.unleash.UnleashClient" + ) as mock_unleash_client: + mock_unleash_client.return_value = mock_client + + provider = UnleashProvider( + url="http://localhost:4242", app_name="test-app", api_token="test-token" + ) + provider.initialize() + + # Test that all required methods exist + assert hasattr(provider, "get_metadata") + assert hasattr(provider, "resolve_boolean_details") + assert hasattr(provider, "resolve_string_details") + assert hasattr(provider, "resolve_integer_details") + assert hasattr(provider, "resolve_float_details") + assert hasattr(provider, "resolve_object_details") + assert hasattr(provider, "initialize") + assert hasattr(provider, "get_status") + assert hasattr(provider, "shutdown") + assert hasattr(provider, "on_context_changed") + assert hasattr(provider, "add_handler") + assert hasattr(provider, "remove_handler") + assert hasattr(provider, "track") + + provider.shutdown() def test_unleash_provider_hooks(): diff --git a/uv.lock b/uv.lock index ceb983f1..28a3560b 100644 --- a/uv.lock +++ b/uv.lock @@ -1011,8 +1011,10 @@ dependencies = [ dev = [ { name = "coverage", extra = ["toml"] }, { name = "mypy", extra = ["faster-cache"] }, + { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "testcontainers" }, { name = "types-requests" }, ] @@ -1026,8 +1028,10 @@ requires-dist = [ dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, { name = "mypy", extras = ["faster-cache"], specifier = ">=1.17.0,<2.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.0,<3.0.0" }, { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "testcontainers", specifier = ">=4.12.0,<5.0.0" }, { name = "types-requests", specifier = ">=2.31.0" }, ] @@ -1173,6 +1177,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, ] +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/81/331257dbf2801cdb82105306042f7a1637cc752f65f2bb688188e0de5f0b/psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", size = 3043397, upload-time = "2024-10-16T11:18:58.647Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9a/7f4f2f031010bbfe6a02b4a15c01e12eb6b9b7b358ab33229f28baadbfc1/psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", size = 3274806, upload-time = "2024-10-16T11:19:03.935Z" }, + { url = "https://files.pythonhosted.org/packages/e5/57/8ddd4b374fa811a0b0a0f49b6abad1cde9cb34df73ea3348cc283fcd70b4/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", size = 2851361, upload-time = "2024-10-16T11:19:07.277Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/d1e52c20d283f1f3a8e7e5c1e06851d432f123ef57b13043b4f9b21ffa1f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", size = 3080836, upload-time = "2024-10-16T11:19:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cb/592d44a9546aba78f8a1249021fe7c59d3afb8a0ba51434d6610cc3462b6/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", size = 3264552, upload-time = "2024-10-16T11:19:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/64/33/c8548560b94b7617f203d7236d6cdf36fe1a5a3645600ada6efd79da946f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", size = 3019789, upload-time = "2024-10-16T11:19:18.889Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0e/c2da0db5bea88a3be52307f88b75eec72c4de62814cbe9ee600c29c06334/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", size = 2871776, upload-time = "2024-10-16T11:19:23.023Z" }, + { url = "https://files.pythonhosted.org/packages/15/d7/774afa1eadb787ddf41aab52d4c62785563e29949613c958955031408ae6/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", size = 2820959, upload-time = "2024-10-16T11:19:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ed/440dc3f5991a8c6172a1cde44850ead0e483a375277a1aef7cfcec00af07/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", size = 2919329, upload-time = "2024-10-16T11:19:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/03/be/2cc8f4282898306732d2ae7b7378ae14e8df3c1231b53579efa056aae887/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", size = 2957659, upload-time = "2024-10-16T11:19:32.864Z" }, + { url = "https://files.pythonhosted.org/packages/d0/12/fb8e4f485d98c570e00dad5800e9a2349cfe0f71a767c856857160d343a5/psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", size = 1024605, upload-time = "2024-10-16T11:19:35.462Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/217cd2471ecf45d82905dd09085e049af8de6cfdc008b6663c3226dc1c98/psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", size = 1163817, upload-time = "2024-10-16T11:19:37.384Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397, upload-time = "2024-10-16T11:19:40.033Z" }, + { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806, upload-time = "2024-10-16T11:19:43.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370, upload-time = "2024-10-16T11:19:46.986Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780, upload-time = "2024-10-16T11:19:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583, upload-time = "2024-10-16T11:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831, upload-time = "2024-10-16T11:19:57.762Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822, upload-time = "2024-10-16T11:20:04.693Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975, upload-time = "2024-10-16T11:20:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320, upload-time = "2024-10-16T11:20:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617, upload-time = "2024-10-16T11:20:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618, upload-time = "2024-10-16T11:20:27.718Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816, upload-time = "2024-10-16T11:20:30.777Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/e77648009b6e61af327c607543f65fdf25bcfb4100f5a6f3bdb62ddac03c/psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", size = 3043437, upload-time = "2024-10-16T11:23:42.946Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e8/5a12211a1f5b959f3e3ccd342eace60c1f26422f53e06d687821dc268780/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", size = 2851340, upload-time = "2024-10-16T11:23:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/47/ed/5932b0458a7fc61237b653df050513c8d18a6f4083cc7f90dcef967f7bce/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", size = 3080905, upload-time = "2024-10-16T11:23:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/71/df/8047d85c3d23864aca4613c3be1ea0fe61dbe4e050a89ac189f9dce4403e/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", size = 3264640, upload-time = "2024-10-16T11:24:06.122Z" }, + { url = "https://files.pythonhosted.org/packages/f3/de/6157e4ef242920e8f2749f7708d5cc8815414bdd4a27a91996e7cd5c80df/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", size = 3019812, upload-time = "2024-10-16T11:24:17.025Z" }, + { url = "https://files.pythonhosted.org/packages/25/f9/0fc49efd2d4d6db3a8d0a3f5749b33a0d3fdd872cad49fbf5bfce1c50027/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", size = 2871933, upload-time = "2024-10-16T11:24:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/2ed1bd182219065692ed458d218d311b0b220b20662d25d913bc4e8d3549/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", size = 2820990, upload-time = "2024-10-16T11:24:29.571Z" }, + { url = "https://files.pythonhosted.org/packages/71/2a/43f77a9b8ee0b10e2de784d97ddc099d9fe0d9eec462a006e4d2cc74756d/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", size = 2919352, upload-time = "2024-10-16T11:24:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/86/d2943df70469e6afab3b5b8e1367fccc61891f46de436b24ddee6f2c8404/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", size = 2957614, upload-time = "2024-10-16T11:24:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/85/21/195d69371330983aa16139e60ba855d0a18164c9295f3a3696be41bbcd54/psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", size = 1025341, upload-time = "2024-10-16T11:24:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/ad/53/73196ebc19d6fbfc22427b982fbc98698b7b9c361e5e7707e3a3247cf06d/psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", size = 1163958, upload-time = "2024-10-16T11:24:51.882Z" }, +] + [[package]] name = "pygments" version = "2.19.2" From a642d468e9291f597aa00c8f064a28168306ddae Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 28 Aug 2025 18:11:40 +0700 Subject: [PATCH 18/29] feat(provider): improve type safety Signed-off-by: Kiki L Hakiem --- providers/openfeature-provider-unleash/README.md | 12 ++++++------ .../openfeature/contrib/provider/unleash/events.py | 3 ++- .../contrib/provider/unleash/flag_evaluation.py | 9 +++------ .../openfeature/contrib/provider/unleash/tracking.py | 3 ++- .../tests/test_flag_evaluation.py | 2 +- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 5ca5a5e6..a1f9df42 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -1,6 +1,6 @@ # Unleash Provider for OpenFeature -This provider is designed to use [Unleash](https://unleash.io/). +This provider is designed to use [Unleash](https://www.getunleash.io/). ## Installation @@ -18,9 +18,9 @@ from openfeature.contrib.provider.unleash import UnleashProvider # Initialize the provider with your Unleash configuration provider = UnleashProvider( - url="https://your-unleash-instance.com", + url="https://my-unleash-instance.com", app_name="my-python-app", - api_token="your-api-token" + api_token="my-api-token" ) # Initialize the provider (required before use) @@ -127,7 +127,7 @@ is_enabled = client.get_boolean_value("my-feature", False) print(f"Feature is enabled: {is_enabled}") # String flag evaluation with context -context = {"userId": "user123", "sessionId": "session456"} +context = EvaluationContext(targeting_key="user123", attributes={"sessionId": "session456"}) variant = client.get_string_value("my-variant-flag", "default", context) print(f"Variant: {variant}") @@ -149,13 +149,13 @@ provider.shutdown() ### Running tests ```bash -pytest +uv run test --frozen ``` ### Type checking ```bash -mypy src/ +uv run mypy-check ``` ## License diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py index 5d9ca511..1a3075d0 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py @@ -4,6 +4,7 @@ from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent from openfeature.event import ProviderEvent +from openfeature.provider import Metadata class UnleashProvider(Protocol): @@ -14,7 +15,7 @@ def _event_handlers(self) -> dict[ProviderEvent, List[Callable]]: """Event handlers dictionary.""" ... - def get_metadata(self) -> Any: + def get_metadata(self) -> Metadata: """Get provider metadata.""" ... diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index b90ee57f..4e151be9 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -2,6 +2,7 @@ from typing import Any, Callable, Optional, Protocol +from UnleashClient import UnleashClient from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( FlagNotFoundError, @@ -17,7 +18,7 @@ class UnleashProvider(Protocol): """Protocol defining the interface needed by FlagEvaluator.""" @property - def client(self) -> Optional[Any]: ... + def client(self) -> Optional[UnleashClient]: ... @property def app_name(self) -> str: ... @@ -196,11 +197,7 @@ def _resolve_variant_flag( value = value_converter(payload_value) return FlagResolutionDetails( value=value, - reason=( - Reason.TARGETING_MATCH - if value != default_value - else Reason.DEFAULT - ), + reason=Reason.TARGETING_MATCH, variant=variant.get("name"), error_code=None, error_message=None, diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py index 1dfc5396..f39ea482 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py @@ -3,6 +3,7 @@ from typing import Any, Optional, Protocol import uuid +from UnleashClient import UnleashClient from UnleashClient.events import UnleashEvent, UnleashEventType from openfeature.evaluation_context import EvaluationContext @@ -11,7 +12,7 @@ class UnleashProvider(Protocol): """Protocol defining the interface needed by Tracker.""" @property - def client(self) -> Optional[Any]: ... + def client(self) -> Optional[UnleashClient]: ... def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None diff --git a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py index d2075f1f..9be56b0c 100644 --- a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py +++ b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py @@ -441,7 +441,7 @@ def test_variant_flag_scenarios(): } result = provider.resolve_string_details("test_flag", "default") assert result.value == "default" - assert result.reason == Reason.DEFAULT + assert result.reason == Reason.TARGETING_MATCH assert result.variant == "test-variant" provider.shutdown() From c70ed7bb99ba3ad4a63f4d58e20eedeb09994963 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 28 Aug 2025 18:41:19 +0700 Subject: [PATCH 19/29] style: fix issues reported by ruff check Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 8 ++++-- .../contrib/provider/unleash/events.py | 11 ++++---- .../provider/unleash/flag_evaluation.py | 28 ++++++++----------- .../contrib/provider/unleash/tracking.py | 3 +- .../tests/test_events.py | 7 ++--- .../tests/test_flag_evaluation.py | 6 ++-- .../tests/test_integration.py | 13 ++++----- .../tests/test_tracking.py | 2 -- 8 files changed, 34 insertions(+), 44 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index ffaaa10d..87c8c7fe 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -1,7 +1,9 @@ -from typing import Any, Callable, List, Mapping, Optional, Sequence, Union +from collections.abc import Mapping, Sequence +from typing import Any, Callable, Optional, Union from UnleashClient import UnleashClient from UnleashClient.events import BaseEvent, UnleashReadyEvent + from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent from openfeature.exception import ErrorCode, GeneralError @@ -39,7 +41,7 @@ def __init__( self.client: Optional[UnleashClient] = None self._status = ProviderStatus.NOT_READY self._last_context: Optional[EvaluationContext] = None - self._event_handlers: dict[ProviderEvent, List[Callable]] = { + self._event_handlers: dict[ProviderEvent, list[Callable]] = { ProviderEvent.PROVIDER_READY: [], ProviderEvent.PROVIDER_ERROR: [], ProviderEvent.PROVIDER_CONFIGURATION_CHANGED: [], @@ -85,7 +87,7 @@ def get_metadata(self) -> Metadata: """Get provider metadata.""" return Metadata(name="Unleash Provider") - def get_provider_hooks(self) -> List[Hook]: + def get_provider_hooks(self) -> list[Hook]: """Get provider hooks.""" return [] diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py index 1a3075d0..5ed75cd6 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py @@ -1,8 +1,10 @@ """Events functionality for Unleash provider.""" -from typing import Any, Callable, List, Protocol +from contextlib import suppress +from typing import Any, Callable, Protocol from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent + from openfeature.event import ProviderEvent from openfeature.provider import Metadata @@ -11,7 +13,7 @@ class UnleashProvider(Protocol): """Protocol defining the interface expected from UnleashProvider for events.""" @property - def _event_handlers(self) -> dict[ProviderEvent, List[Callable]]: + def _event_handlers(self) -> dict[ProviderEvent, list[Callable]]: """Event handlers dictionary.""" ... @@ -67,11 +69,8 @@ def emit_event(self, event_type: ProviderEvent, **kwargs: Any) -> None: **kwargs, } for handler in self._provider._event_handlers[event_type]: - try: + with suppress(Exception): handler(event_details) - except Exception: - # Ignore handler errors to prevent breaking other handlers - pass def handle_unleash_event(self, event: BaseEvent) -> None: """Handle UnleashClient events and translate them to OpenFeature events. diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index 4e151be9..43d41b63 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -1,8 +1,11 @@ """Flag evaluation functionality for Unleash provider.""" +import json from typing import Any, Callable, Optional, Protocol +import requests from UnleashClient import UnleashClient + from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( FlagNotFoundError, @@ -11,7 +14,6 @@ TypeMismatchError, ) from openfeature.flag_evaluation import FlagResolutionDetails, Reason -import requests class UnleashProvider(Protocol): @@ -76,13 +78,12 @@ def resolve_boolean_details( ) except requests.exceptions.HTTPError as e: if e.response and e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {flag_key}") - raise GeneralError(f"HTTP error: {e}") + raise FlagNotFoundError(f"Flag not found: {flag_key}") from e + raise GeneralError(f"HTTP error: {e}") from e except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): - # Re-raise specific OpenFeature exceptions raise except Exception as e: - raise GeneralError(f"Unexpected error: {e}") + raise GeneralError(f"Unexpected error: {e}") from e def resolve_string_details( self, @@ -186,11 +187,9 @@ def _resolve_variant_flag( raise GeneralError("Provider not initialized. Call initialize() first.") try: - # Use get_variant to get the variant payload context = self._provider._build_unleash_context(evaluation_context) variant = self._provider.client.get_variant(flag_key, context=context) - # Check if the feature is enabled and has a payload if variant.get("enabled", False) and "payload" in variant: try: payload_value = variant["payload"].get("value", default_value) @@ -209,10 +208,8 @@ def _resolve_variant_flag( }, ) except (ValueError, TypeError) as e: - # If payload value can't be converted, raise TypeMismatchError - raise TypeMismatchError(str(e)) + raise TypeMismatchError(str(e)) from e except ParseError: - # Re-raise ParseError directly raise else: return FlagResolutionDetails( @@ -229,13 +226,12 @@ def _resolve_variant_flag( ) except requests.exceptions.HTTPError as e: if e.response and e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {flag_key}") - raise GeneralError(f"HTTP error: {e}") + raise FlagNotFoundError(f"Flag not found: {flag_key}") from e + raise GeneralError(f"HTTP error: {e}") from e except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): - # Re-raise specific OpenFeature exceptions raise except Exception as e: - raise GeneralError(f"Unexpected error: {e}") + raise GeneralError(f"Unexpected error: {e}") from e def _parse_json(self, value: Any) -> Any: """Parse JSON value for object flags. @@ -249,11 +245,9 @@ def _parse_json(self, value: Any) -> Any: Raises: ParseError: If JSON parsing fails """ - import json - if isinstance(value, str): try: return json.loads(value) except json.JSONDecodeError as e: - raise ParseError(f"Invalid JSON: {e}") + raise ParseError(f"Invalid JSON: {e}") from e return value diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py index f39ea482..8c6aa680 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py @@ -1,10 +1,11 @@ """Tracking functionality for Unleash provider.""" -from typing import Any, Optional, Protocol import uuid +from typing import Any, Optional, Protocol from UnleashClient import UnleashClient from UnleashClient.events import UnleashEvent, UnleashEventType + from openfeature.evaluation_context import EvaluationContext diff --git a/providers/openfeature-provider-unleash/tests/test_events.py b/providers/openfeature-provider-unleash/tests/test_events.py index c16c4c56..41abda0b 100644 --- a/providers/openfeature-provider-unleash/tests/test_events.py +++ b/providers/openfeature-provider-unleash/tests/test_events.py @@ -1,18 +1,19 @@ """Tests for events functionality.""" -from unittest.mock import Mock, patch import uuid +from unittest.mock import Mock, patch +import pytest from UnleashClient.events import ( UnleashEventType, UnleashFetchedEvent, UnleashReadyEvent, ) + from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.event import ProviderEvent from openfeature.exception import GeneralError from openfeature.provider import ProviderStatus -import pytest def test_events(): @@ -71,8 +72,6 @@ def on_config_changed(event_details): provider.remove_handler(ProviderEvent.PROVIDER_READY, on_ready) provider.shutdown() # Should not trigger ready event again - provider.shutdown() - def test_unleash_event_callback(): """Test that UnleashProvider handles UnleashClient events correctly.""" diff --git a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py index 9be56b0c..15ab642d 100644 --- a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py +++ b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py @@ -2,6 +2,9 @@ from unittest.mock import Mock, patch +import pytest +import requests + from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( @@ -11,8 +14,6 @@ TypeMismatchError, ) from openfeature.flag_evaluation import Reason -import pytest -import requests def test_resolve_boolean_details(): @@ -128,7 +129,6 @@ def test_with_evaluation_context(): flag = provider.resolve_boolean_details("test_flag", False, context) assert flag.value is True - # Verify that context was passed to UnleashClient mock_client.is_enabled.assert_called_with( "test_flag", context={"userId": "user123", "email": "user@example.com", "country": "US"}, diff --git a/providers/openfeature-provider-unleash/tests/test_integration.py b/providers/openfeature-provider-unleash/tests/test_integration.py index 47567354..b41b4ad9 100644 --- a/providers/openfeature-provider-unleash/tests/test_integration.py +++ b/providers/openfeature-provider-unleash/tests/test_integration.py @@ -1,17 +1,18 @@ """Integration tests for Unleash provider using testcontainers.""" -from datetime import datetime, timezone import time +from datetime import datetime, timezone -from openfeature import api -from openfeature.contrib.provider.unleash import UnleashProvider -from openfeature.evaluation_context import EvaluationContext import psycopg2 import pytest import requests from testcontainers.core.container import DockerContainer from testcontainers.postgres import PostgresContainer +from openfeature import api +from openfeature.contrib.provider.unleash import UnleashProvider +from openfeature.evaluation_context import EvaluationContext + # Configuration for the running Unleash instance (will be set by fixtures) UNLEASH_URL = None API_TOKEN = "default:development.unleash-insecure-api-token" @@ -288,7 +289,6 @@ def unleash_container(postgres_container): while time.time() - start_time < max_wait_time: try: - # Get the exposed port try: exposed_port = container.get_exposed_port(4242) unleash_url = f"http://localhost:{exposed_port}" @@ -298,7 +298,6 @@ def unleash_container(postgres_container): time.sleep(2) continue - # Try to connect to health endpoint response = requests.get(f"{unleash_url}/health", timeout=5) if response.status_code == 200: print("Unleash container is healthy!") @@ -341,14 +340,12 @@ def unleash_provider(setup_test_flags): ) provider.initialize() yield provider - # Clean up the provider to avoid multiple UnleashClient instances provider.shutdown() @pytest.fixture(scope="session") def client(unleash_provider): """Create an OpenFeature client with the Unleash provider.""" - # Set the provider globally api.set_provider(unleash_provider) return api.get_client() diff --git a/providers/openfeature-provider-unleash/tests/test_tracking.py b/providers/openfeature-provider-unleash/tests/test_tracking.py index 6862ff40..3a9d54e2 100644 --- a/providers/openfeature-provider-unleash/tests/test_tracking.py +++ b/providers/openfeature-provider-unleash/tests/test_tracking.py @@ -22,13 +22,11 @@ def test_track_basic(): ) provider.initialize() - # Set the event callback provider._unleash_event_callback = mock_event_callback # Track a basic event provider.track("user_action") - # Verify the tracking event was created and passed to callback assert mock_event_callback.call_count == 1 tracking_event = mock_event_callback.call_args[0][0] assert tracking_event.feature_name == "user_action" From 4bea9d71ac697fd0fa51b4e79331a9076617d533 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 28 Aug 2025 18:51:11 +0700 Subject: [PATCH 20/29] style: add ruff config Signed-off-by: Kiki L Hakiem --- .../pyproject.toml | 63 +++++++++++++++++++ .../src/scripts/scripts.py | 5 ++ uv.lock | 28 +++++++++ 3 files changed, 96 insertions(+) diff --git a/providers/openfeature-provider-unleash/pyproject.toml b/providers/openfeature-provider-unleash/pyproject.toml index d0d0a301..89c0ccfd 100644 --- a/providers/openfeature-provider-unleash/pyproject.toml +++ b/providers/openfeature-provider-unleash/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "psycopg2-binary>=2.9.0,<3.0.0", "testcontainers>=4.12.0,<5.0.0", "types-requests>=2.31.0", + "ruff>=0.12.10", ] [tool.uv.build-backend] @@ -69,6 +70,68 @@ markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", ] +[tool.ruff] +target-version = "py39" +exclude = [ + ".git", + ".venv", + "__pycache__", + "venv", +] + +[tool.ruff.lint] +select = [ + "A", + "B", + "C4", + "C90", + "E", + "F", + "FLY", + "FURB", + "I", + "LOG", + "N", + "PERF", + "PGH", + "PLC", + "PLR0913", + "PLR0915", + "RUF", + "S", + "SIM", + "T10", + "T20", + "UP", + "W", + "YTT", +] +ignore = [ + "E501", # the formatter will handle any too long line +] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**/*" = [ + "S101", # Use of assert detected + "S105", # Possible hardcoded password + "S106", # Possible hardcoded password assigned to argument + "S104", # Possible binding to all interfaces + "T201", # print found + "PERF203", # try-except within a loop incurs performance overhead + "PLR0915", # Too many statements +] + +[tool.ruff.lint.isort] +known-first-party = ["openfeature"] + +[tool.ruff.lint.pylint] +max-args = 6 +max-statements = 30 + +[tool.ruff.lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = true + [project.scripts] # workaround while UV doesn't support scripts directly in the pyproject.toml # see: https://github.com/astral-sh/uv/issues/5903 diff --git a/providers/openfeature-provider-unleash/src/scripts/scripts.py b/providers/openfeature-provider-unleash/src/scripts/scripts.py index 2787d652..00a54530 100644 --- a/providers/openfeature-provider-unleash/src/scripts/scripts.py +++ b/providers/openfeature-provider-unleash/src/scripts/scripts.py @@ -26,3 +26,8 @@ def cov() -> None: def mypy() -> None: """Run mypy.""" subprocess.run("mypy", shell=True, check=True) + + +def lint() -> None: + """Run ruff linting.""" + subprocess.run("ruff check", shell=True, check=True) diff --git a/uv.lock b/uv.lock index 28a3560b..30b58ee4 100644 --- a/uv.lock +++ b/uv.lock @@ -1014,6 +1014,7 @@ dev = [ { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, { name = "testcontainers" }, { name = "types-requests" }, ] @@ -1031,6 +1032,7 @@ dev = [ { name = "psycopg2-binary", specifier = ">=2.9.0,<3.0.0" }, { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "ruff", specifier = ">=0.12.10" }, { name = "testcontainers", specifier = ">=4.12.0,<5.0.0" }, { name = "types-requests", specifier = ">=2.31.0" }, ] @@ -1448,6 +1450,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, ] +[[package]] +name = "ruff" +version = "0.12.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076, upload-time = "2025-08-21T18:23:22.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161, upload-time = "2025-08-21T18:22:26.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884, upload-time = "2025-08-21T18:22:30.925Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754, upload-time = "2025-08-21T18:22:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276, upload-time = "2025-08-21T18:22:36.764Z" }, + { url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700, upload-time = "2025-08-21T18:22:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783, upload-time = "2025-08-21T18:22:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642, upload-time = "2025-08-21T18:22:45.612Z" }, + { url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107, upload-time = "2025-08-21T18:22:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521, upload-time = "2025-08-21T18:22:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528, upload-time = "2025-08-21T18:22:54.609Z" }, + { url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443, upload-time = "2025-08-21T18:22:57.413Z" }, + { url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759, upload-time = "2025-08-21T18:23:00.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463, upload-time = "2025-08-21T18:23:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603, upload-time = "2025-08-21T18:23:06.935Z" }, + { url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356, upload-time = "2025-08-21T18:23:10.225Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089, upload-time = "2025-08-21T18:23:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616, upload-time = "2025-08-21T18:23:17.422Z" }, + { url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225, upload-time = "2025-08-21T18:23:20.137Z" }, +] + [[package]] name = "semver" version = "3.0.4" From 012de8f02ba7719aa9120291e833be160d7421a4 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Tue, 2 Sep 2025 14:52:57 +0700 Subject: [PATCH 21/29] docs: update README.md Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index a1f9df42..d3cd486f 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -8,6 +8,12 @@ This provider is designed to use [Unleash](https://www.getunleash.io/). pip install openfeature-provider-unleash ``` +### Requirements + +- Python 3.9+ +- `openfeature-sdk>=0.8.2` +- `UnleashClient>=6.3.0` + ## Configuration and Usage Instantiate a new UnleashProvider instance and configure the OpenFeature SDK to use it: @@ -20,7 +26,8 @@ from openfeature.contrib.provider.unleash import UnleashProvider provider = UnleashProvider( url="https://my-unleash-instance.com", app_name="my-python-app", - api_token="my-api-token" + api_token="my-api-token", + environment="development", # optional, defaults to "development" ) # Initialize the provider (required before use) @@ -34,6 +41,14 @@ api.set_provider(provider) - `url`: The URL of your Unleash server - `app_name`: The name of your application - `api_token`: The API token for authentication +- `environment`: The Unleash environment to connect to (default: `development`) + +### Evaluation context mapping + +When evaluating flags, the OpenFeature `EvaluationContext` is mapped to the Unleash context as follows: + +- `EvaluationContext.targeting_key` → Unleash `userId` +- `EvaluationContext.attributes` → merged into the Unleash context as-is ### Event handling @@ -66,7 +81,8 @@ provider.remove_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) - `ProviderEvent.PROVIDER_READY`: Emitted when the provider is ready to evaluate flags - `ProviderEvent.PROVIDER_ERROR`: Emitted when the provider encounters an error - `ProviderEvent.PROVIDER_CONFIGURATION_CHANGED`: Emitted when flag configurations are updated -- `ProviderEvent.PROVIDER_STALE`: Emitted when the provider's cached state is no longer valid + +Note: `ProviderEvent.PROVIDER_STALE` handlers can be registered but are not currently emitted by this provider. ### Tracking support @@ -103,6 +119,16 @@ provider.track("conversion", context, event_details) - **Event Details**: Add numeric values and custom fields for analytics - **Unleash Integration**: Uses UnleashClient's impression event infrastructure +### Supported flag types + +This provider supports resolving the following types via the OpenFeature client: + +- Boolean (`get_boolean_value`/`details`): uses `UnleashClient.is_enabled` +- String (`get_string_value`/`details`): from variant payload +- Integer (`get_integer_value`/`details`): from variant payload +- Float (`get_float_value`/`details`): from variant payload +- Object (`get_object_value`/`details`): from variant payload, JSON-parsed if needed + ### Example usage ```python @@ -152,12 +178,32 @@ provider.shutdown() uv run test --frozen ``` +Integration tests require Docker to be installed and running. To run only integration tests: + +```bash +uv run test -m integration --frozen +``` + +To skip integration tests: + +```bash +uv run test -m "not integration" --frozen +``` + ### Type checking ```bash uv run mypy-check ``` +### Linting + +Run Ruff checks: + +```bash +uv run ruff check +``` + ## License Apache 2.0 - See [LICENSE](./LICENSE) for more information. From 555aec275378ecb79715b04b0e9c67915e58f38c Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 3 Sep 2025 14:15:46 +0700 Subject: [PATCH 22/29] refactor(provider): remove impossible error handling for Unleash Signed-off-by: Kiki L Hakiem --- .../provider/unleash/flag_evaluation.py | 14 +- .../tests/test_flag_evaluation.py | 128 ------------------ 2 files changed, 2 insertions(+), 140 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index 43d41b63..2c79113f 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -3,12 +3,10 @@ import json from typing import Any, Callable, Optional, Protocol -import requests from UnleashClient import UnleashClient from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( - FlagNotFoundError, GeneralError, ParseError, TypeMismatchError, @@ -76,11 +74,7 @@ def resolve_boolean_details( "app_name": self._provider.app_name, }, ) - except requests.exceptions.HTTPError as e: - if e.response and e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {flag_key}") from e - raise GeneralError(f"HTTP error: {e}") from e - except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): + except (TypeMismatchError, ParseError, GeneralError): raise except Exception as e: raise GeneralError(f"Unexpected error: {e}") from e @@ -224,11 +218,7 @@ def _resolve_variant_flag( "app_name": self._provider.app_name, }, ) - except requests.exceptions.HTTPError as e: - if e.response and e.response.status_code == 404: - raise FlagNotFoundError(f"Flag not found: {flag_key}") from e - raise GeneralError(f"HTTP error: {e}") from e - except (FlagNotFoundError, TypeMismatchError, ParseError, GeneralError): + except (TypeMismatchError, ParseError, GeneralError): raise except Exception as e: raise GeneralError(f"Unexpected error: {e}") from e diff --git a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py index 15ab642d..1a46f704 100644 --- a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py +++ b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py @@ -3,13 +3,10 @@ from unittest.mock import Mock, patch import pytest -import requests from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( - FlagNotFoundError, - GeneralError, ParseError, TypeMismatchError, ) @@ -37,30 +34,6 @@ def test_resolve_boolean_details(): assert flag.reason == Reason.TARGETING_MATCH -def test_resolve_boolean_details_error(): - """Test that FlagEvaluator handles errors gracefully.""" - mock_client = Mock() - mock_client.is_enabled.side_effect = requests.exceptions.ConnectionError( - "Connection error" - ) - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - with pytest.raises(GeneralError) as exc_info: - provider.resolve_boolean_details("test_flag", True) - assert "Connection error" in str(exc_info.value) - - provider.shutdown() - - @pytest.mark.parametrize( "method_name, payload_value, expected_value, default_value", [ @@ -195,66 +168,6 @@ def test_value_conversion_errors( provider.shutdown() -@pytest.mark.parametrize( - "method_name, mock_side_effect, default_value, expected_error_message, expected_exception", - [ - ( - "resolve_string_details", - requests.exceptions.HTTPError( - "404 Client Error: Not Found", response=Mock(status_code=404) - ), - "default", - "Flag not found", - "FlagNotFoundError", - ), - ( - "resolve_boolean_details", - requests.exceptions.ConnectionError("Connection error"), - True, - "Connection error", - "GeneralError", - ), - ], -) -def test_general_errors( - method_name, - mock_side_effect, - default_value, - expected_error_message, - expected_exception, -): - """Test that FlagEvaluator handles general errors correctly.""" - mock_client = Mock() - - if method_name == "resolve_boolean_details": - mock_client.is_enabled.side_effect = mock_side_effect - else: - mock_client.get_variant.side_effect = mock_side_effect - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - method = getattr(provider, method_name) - - if expected_exception == "FlagNotFoundError": - with pytest.raises(FlagNotFoundError) as exc_info: - method("non_existent_flag", default_value) - assert expected_error_message in str(exc_info.value) - else: - with pytest.raises(GeneralError) as exc_info: - method("test_flag", default_value) - assert expected_error_message in str(exc_info.value) - - provider.shutdown() - - def test_edge_cases(): """Test FlagEvaluator with edge cases and boundary conditions.""" mock_client = Mock() @@ -295,47 +208,6 @@ def test_edge_cases(): provider.shutdown() -@pytest.mark.parametrize( - "mock_side_effect, expected_error_message", - [ - ( - requests.exceptions.HTTPError( - "429 Too Many Requests", response=Mock(status_code=429) - ), - "HTTP error", - ), - ( - requests.exceptions.Timeout("Request timeout"), - "Unexpected error", - ), - ( - requests.exceptions.SSLError("SSL certificate error"), - "Unexpected error", - ), - ], -) -def test_network_errors(mock_side_effect, expected_error_message): - """Test that FlagEvaluator handles network errors correctly.""" - mock_client = Mock() - mock_client.is_enabled.side_effect = mock_side_effect - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - with pytest.raises(GeneralError) as exc_info: - provider.resolve_boolean_details("test_flag", True) - assert expected_error_message in str(exc_info.value) - - provider.shutdown() - - def test_context_without_targeting_key(): """Test that FlagEvaluator works with context without targeting key.""" mock_client = Mock() From eefd822a374f466c9b8f047f6215ae003cb0b5e9 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Thu, 4 Sep 2025 17:14:47 +0700 Subject: [PATCH 23/29] feat(provider): allow injecting custom cache to Unleash client Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 10 ++- .../tests/test_provider.py | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 87c8c7fe..ae723365 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -2,6 +2,7 @@ from typing import Any, Callable, Optional, Union from UnleashClient import UnleashClient +from UnleashClient.cache import BaseCache from UnleashClient.events import BaseEvent, UnleashReadyEvent from openfeature.evaluation_context import EvaluationContext @@ -25,6 +26,8 @@ def __init__( app_name: str, api_token: str, environment: str = "development", + fetch_toggles: bool = True, + cache: Optional[BaseCache] = None, ) -> None: """Initialize the Unleash provider. @@ -33,11 +36,14 @@ def __init__( app_name: The application name api_token: The API token for authentication environment: The environment to connect to (default: "development") + fetch_toggles: Whether to fetch toggles from server on initialization (default: True) + cache: Optional cache implementation to use (default: UnleashClient's default) """ self.url = url self.app_name = app_name self.api_token = api_token self.environment = environment + self.cache = cache self.client: Optional[UnleashClient] = None self._status = ProviderStatus.NOT_READY self._last_context: Optional[EvaluationContext] = None @@ -50,6 +56,7 @@ def __init__( self._tracking_manager = Tracker(self) self._event_manager = EventManager(self) self._flag_evaluator = FlagEvaluator(self) + self.fetch_toggles = fetch_toggles def initialize( self, evaluation_context: Optional[EvaluationContext] = None @@ -66,8 +73,9 @@ def initialize( environment=self.environment, custom_headers={"Authorization": self.api_token}, event_callback=self._unleash_event_callback, + cache=self.cache, ) - self.client.initialize_client() + self.client.initialize_client(fetch_toggles=self.fetch_toggles) self._status = ProviderStatus.READY self._event_manager.emit_event(ProviderEvent.PROVIDER_READY) except Exception as e: diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index ae8e4b40..32fe2f13 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -5,6 +5,32 @@ from openfeature.provider import ProviderStatus +# Mock feature response for testing cache functionality +MOCK_FEATURE_RESPONSE = { + "version": 1, + "features": [ + { + "name": "testFlag", + "description": "This is a test!", + "enabled": True, + "strategies": [{"name": "default", "parameters": {}}], + "createdAt": "2018-10-04T01:27:28.477Z", + "impressionData": True, + }, + { + "name": "testFlag2", + "description": "Test flag 2", + "enabled": False, + "strategies": [ + {"name": "gradualRolloutRandom", "parameters": {"percentage": "50"}} + ], + "createdAt": "2018-10-04T11:03:56.062Z", + "impressionData": False, + }, + ], +} + + def test_unleash_provider_import(): """Test that UnleashProvider can be imported.""" assert UnleashProvider is not None @@ -160,3 +186,49 @@ def test_unleash_provider_flag_metadata(): assert result.flag_metadata["app_name"] == "test-app" provider.shutdown() + + +def test_unleash_provider_with_custom_cache(): + """Test that UnleashProvider properly uses a custom cache with mocked features.""" + from UnleashClient.cache import FileCache + + # Create a custom cache with mocked features + custom_cache = FileCache("test-app") + custom_cache.bootstrap_from_dict(MOCK_FEATURE_RESPONSE) + + # Create provider with custom cache + provider = UnleashProvider( + url="http://localhost:4242", + app_name="test-app", + api_token="test-token", + cache=custom_cache, + fetch_toggles=False, + ) + + # Verify cache was stored + assert provider.cache is custom_cache + + # Initialize the provider with fetch_toggles=False to prevent server connection + provider.initialize() + + # Verify the provider is ready + assert provider.get_status() == ProviderStatus.READY + assert provider.client is not None + + # Test flag evaluation using the cached features + # testFlag should be enabled (True in mock data) + result = provider.resolve_boolean_details("testFlag", False) + assert result.value is True + assert result.reason.value == "TARGETING_MATCH" + + # testFlag2 should be disabled (False in mock data) + result = provider.resolve_boolean_details("testFlag2", True) + assert result.value is False + assert result.reason.value == "DEFAULT" + + # Test string resolution with default value for non-existent flag + result = provider.resolve_string_details("nonExistentFlag", "default_value") + assert result.value == "default_value" + assert result.reason.value == "DEFAULT" + + provider.shutdown() From d29c0264b1862aad7e01784cbc97b24107f91050 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 10 Sep 2025 19:16:53 +0700 Subject: [PATCH 24/29] feat(provider): proper Unleash lifecycle event handling Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 47 +++++++++---------- .../contrib/provider/unleash/events.py | 7 ++- .../provider/unleash/flag_evaluation.py | 8 +--- .../tests/test_events.py | 7 ++- .../tests/test_provider.py | 10 +++- 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index ae723365..bcebb1bb 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -3,7 +3,7 @@ from UnleashClient import UnleashClient from UnleashClient.cache import BaseCache -from UnleashClient.events import BaseEvent, UnleashReadyEvent +from UnleashClient.events import BaseEvent, UnleashEventType from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent @@ -44,8 +44,16 @@ def __init__( self.api_token = api_token self.environment = environment self.cache = cache - self.client: Optional[UnleashClient] = None self._status = ProviderStatus.NOT_READY + + self.client: UnleashClient = UnleashClient( + url=self.url, + app_name=self.app_name, + environment=self.environment, + custom_headers={"Authorization": self.api_token}, + event_callback=self._unleash_event_callback, + cache=self.cache, + ) self._last_context: Optional[EvaluationContext] = None self._event_handlers: dict[ProviderEvent, list[Callable]] = { ProviderEvent.PROVIDER_READY: [], @@ -67,17 +75,7 @@ def initialize( evaluation_context: Optional evaluation context (not used for initialization) """ try: - self.client = UnleashClient( - url=self.url, - app_name=self.app_name, - environment=self.environment, - custom_headers={"Authorization": self.api_token}, - event_callback=self._unleash_event_callback, - cache=self.cache, - ) self.client.initialize_client(fetch_toggles=self.fetch_toggles) - self._status = ProviderStatus.READY - self._event_manager.emit_event(ProviderEvent.PROVIDER_READY) except Exception as e: self._status = ProviderStatus.ERROR self._event_manager.emit_event( @@ -101,19 +99,18 @@ def get_provider_hooks(self) -> list[Hook]: def shutdown(self) -> None: """Shutdown the Unleash client.""" - if self.client: - try: + try: + if self.client.is_initialized: self.client.destroy() - self.client = None - self._status = ProviderStatus.NOT_READY - except Exception as e: - self._status = ProviderStatus.ERROR - self._event_manager.emit_event( - ProviderEvent.PROVIDER_ERROR, - error_message=str(e), - error_code=ErrorCode.GENERAL, - ) - raise GeneralError(f"Failed to shutdown Unleash provider: {e}") from e + self._status = ProviderStatus.NOT_READY + except Exception as e: + self._status = ProviderStatus.ERROR + self._event_manager.emit_event( + ProviderEvent.PROVIDER_ERROR, + error_message=str(e), + error_code=ErrorCode.GENERAL, + ) + raise GeneralError(f"Failed to shutdown Unleash provider: {e}") from e def on_context_changed( self, @@ -152,7 +149,7 @@ def _unleash_event_callback(self, event: BaseEvent) -> None: Args: event: The Unleash event """ - if isinstance(event, UnleashReadyEvent): + if event.event_type == UnleashEventType.READY: self._status = ProviderStatus.READY self._event_manager.handle_unleash_event(event) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py index 5ed75cd6..1c1c24f7 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/events.py @@ -3,7 +3,7 @@ from contextlib import suppress from typing import Any, Callable, Protocol -from UnleashClient.events import BaseEvent, UnleashFetchedEvent, UnleashReadyEvent +from UnleashClient.events import BaseEvent, UnleashEventType from openfeature.event import ProviderEvent from openfeature.provider import Metadata @@ -78,10 +78,9 @@ def handle_unleash_event(self, event: BaseEvent) -> None: Args: event: The Unleash event """ - if isinstance(event, UnleashReadyEvent): + if event.event_type == UnleashEventType.READY: self.emit_event(ProviderEvent.PROVIDER_READY) - elif isinstance(event, UnleashFetchedEvent): - # Configuration changed when features are fetched + elif event.event_type == UnleashEventType.FETCHED: flag_keys = [] if hasattr(event, "features"): if isinstance(event.features, dict): diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index 2c79113f..6e7fe813 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -18,7 +18,7 @@ class UnleashProvider(Protocol): """Protocol defining the interface needed by FlagEvaluator.""" @property - def client(self) -> Optional[UnleashClient]: ... + def client(self) -> UnleashClient: ... @property def app_name(self) -> str: ... @@ -55,9 +55,6 @@ def resolve_boolean_details( Returns: FlagResolutionDetails with the resolved boolean value """ - if not self._provider.client: - raise GeneralError("Provider not initialized. Call initialize() first.") - try: context = self._provider._build_unleash_context(evaluation_context) is_enabled = self._provider.client.is_enabled(flag_key, context=context) @@ -177,9 +174,6 @@ def _resolve_variant_flag( Returns: FlagResolutionDetails with the resolved value """ - if not self._provider.client: - raise GeneralError("Provider not initialized. Call initialize() first.") - try: context = self._provider._build_unleash_context(evaluation_context) variant = self._provider.client.get_variant(flag_key, context=context) diff --git a/providers/openfeature-provider-unleash/tests/test_events.py b/providers/openfeature-provider-unleash/tests/test_events.py index 41abda0b..7735aca7 100644 --- a/providers/openfeature-provider-unleash/tests/test_events.py +++ b/providers/openfeature-provider-unleash/tests/test_events.py @@ -50,8 +50,12 @@ def on_config_changed(event_details): ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, on_config_changed ) - # Initialize should emit PROVIDER_READY provider.initialize() + + # Simulate the READY event from UnleashClient + event_callback = mock_unleash_client.call_args[1]["event_callback"] + event_callback(UnleashReadyEvent(UnleashEventType.READY, uuid.uuid4())) + assert len(ready_events) == 1 assert ready_events[0]["provider_name"] == "Unleash Provider" @@ -112,6 +116,7 @@ def test_unleash_event_callback(): # Create a mock UnleashFetchedEvent with features mock_event = Mock(spec=UnleashFetchedEvent) + mock_event.event_type = UnleashEventType.FETCHED mock_event.features = {"flag1": {}, "flag2": {}} event_callback(mock_event) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 32fe2f13..34aba93c 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -1,5 +1,8 @@ +import uuid from unittest.mock import Mock, patch +from UnleashClient.events import UnleashEventType, UnleashReadyEvent + from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.provider import ProviderStatus @@ -73,10 +76,13 @@ def test_unleash_provider_initialization(): # Should start as NOT_READY assert provider.get_status() == ProviderStatus.NOT_READY - # Initialize the provider provider.initialize() - # Should be READY after initialization + # Simulate the READY event from UnleashClient + event_callback = mock_unleash_client.call_args[1]["event_callback"] + event_callback(UnleashReadyEvent(UnleashEventType.READY, uuid.uuid4())) + + # Should be READY after receiving the READY event assert provider.get_status() == ProviderStatus.READY assert provider.client is not None From bef9bac0b4f2564ecc88cce480aac33d5f7dc11f Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Wed, 10 Sep 2025 19:17:43 +0700 Subject: [PATCH 25/29] chore(ci): add Unleash to packages matrix Signed-off-by: Kiki L Hakiem --- .github/workflows/build.yml | 3 +++ .release-please-manifest.json | 3 ++- release-please-config.json | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e0fa69c..2d1efb68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,9 @@ jobs: providers/openfeature-provider-ofrep: - 'providers/openfeature-provider-ofrep/**' - 'uv.lock' + providers/openfeature-provider-unleash: + - 'providers/openfeature-provider-unleash/**' + - 'uv.lock' build: needs: changes diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 123f0a84..c9d35c1b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,6 @@ "providers/openfeature-provider-flagd": "0.2.6", "providers/openfeature-provider-ofrep": "0.2.0", "providers/openfeature-provider-flipt": "0.1.3", - "providers/openfeature-provider-env-var": "0.1.0" + "providers/openfeature-provider-env-var": "0.1.0", + "providers/openfeature-provider-unleash": "0.1.0" } diff --git a/release-please-config.json b/release-please-config.json index f4be87ac..6770a51e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -44,6 +44,15 @@ "README.md" ] }, + "providers/openfeature-provider-unleash": { + "package-name": "openfeature-provider-unleash", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] + }, "hooks/openfeature-hooks-opentelemetry": { "package-name": "openfeature-hooks-opentelemetry", "bump-minor-pre-major": true, From 461c2139b58d7a211aab9bbac59ffa331b883d0d Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Mon, 29 Sep 2025 21:17:08 +0700 Subject: [PATCH 26/29] refactor(provider): remove unused exception handling Signed-off-by: Kiki L Hakiem --- .../contrib/provider/unleash/__init__.py | 15 +-- .../provider/unleash/flag_evaluation.py | 97 ++++++++----------- .../contrib/provider/unleash/tracking.py | 3 - .../tests/test_provider.py | 4 +- uv.lock | 3 +- 5 files changed, 49 insertions(+), 73 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index bcebb1bb..0337fe37 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -99,18 +99,9 @@ def get_provider_hooks(self) -> list[Hook]: def shutdown(self) -> None: """Shutdown the Unleash client.""" - try: - if self.client.is_initialized: - self.client.destroy() - self._status = ProviderStatus.NOT_READY - except Exception as e: - self._status = ProviderStatus.ERROR - self._event_manager.emit_event( - ProviderEvent.PROVIDER_ERROR, - error_message=str(e), - error_code=ErrorCode.GENERAL, - ) - raise GeneralError(f"Failed to shutdown Unleash provider: {e}") from e + if self.client.is_initialized: + self.client.destroy() + self._status = ProviderStatus.NOT_READY def on_context_changed( self, diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index 6e7fe813..6e59cfac 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -7,7 +7,6 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( - GeneralError, ParseError, TypeMismatchError, ) @@ -55,26 +54,21 @@ def resolve_boolean_details( Returns: FlagResolutionDetails with the resolved boolean value """ - try: - context = self._provider._build_unleash_context(evaluation_context) - is_enabled = self._provider.client.is_enabled(flag_key, context=context) - - return FlagResolutionDetails( - value=is_enabled, - reason=Reason.TARGETING_MATCH if is_enabled else Reason.DEFAULT, - variant=None, - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": is_enabled, - "app_name": self._provider.app_name, - }, - ) - except (TypeMismatchError, ParseError, GeneralError): - raise - except Exception as e: - raise GeneralError(f"Unexpected error: {e}") from e + context = self._provider._build_unleash_context(evaluation_context) + is_enabled = self._provider.client.is_enabled(flag_key, context=context) + + return FlagResolutionDetails( + value=is_enabled, + reason=Reason.TARGETING_MATCH if is_enabled else Reason.DEFAULT, + variant=None, + error_code=None, + error_message=None, + flag_metadata={ + "source": "unleash", + "enabled": is_enabled, + "app_name": self._provider.app_name, + }, + ) def resolve_string_details( self, @@ -174,48 +168,43 @@ def _resolve_variant_flag( Returns: FlagResolutionDetails with the resolved value """ - try: - context = self._provider._build_unleash_context(evaluation_context) - variant = self._provider.client.get_variant(flag_key, context=context) - - if variant.get("enabled", False) and "payload" in variant: - try: - payload_value = variant["payload"].get("value", default_value) - value = value_converter(payload_value) - return FlagResolutionDetails( - value=value, - reason=Reason.TARGETING_MATCH, - variant=variant.get("name"), - error_code=None, - error_message=None, - flag_metadata={ - "source": "unleash", - "enabled": variant.get("enabled", False), - "variant_name": variant.get("name") or "", - "app_name": self._provider.app_name, - }, - ) - except (ValueError, TypeError) as e: - raise TypeMismatchError(str(e)) from e - except ParseError: - raise - else: + context = self._provider._build_unleash_context(evaluation_context) + variant = self._provider.client.get_variant(flag_key, context=context) + + if variant.get("enabled", False) and "payload" in variant: + try: + payload_value = variant["payload"].get("value", default_value) + value = value_converter(payload_value) return FlagResolutionDetails( - value=default_value, - reason=Reason.DEFAULT, - variant=None, + value=value, + reason=Reason.TARGETING_MATCH, + variant=variant.get("name"), error_code=None, error_message=None, flag_metadata={ "source": "unleash", "enabled": variant.get("enabled", False), + "variant_name": variant.get("name") or "", "app_name": self._provider.app_name, }, ) - except (TypeMismatchError, ParseError, GeneralError): - raise - except Exception as e: - raise GeneralError(f"Unexpected error: {e}") from e + except ValueError as e: + raise TypeMismatchError(str(e)) from e + except ParseError: + raise + else: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + variant=None, + error_code=None, + error_message=None, + flag_metadata={ + "source": "unleash", + "enabled": variant.get("enabled", False), + "app_name": self._provider.app_name, + }, + ) def _parse_json(self, value: Any) -> Any: """Parse JSON value for object flags. diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py index 8c6aa680..c6d9fdac 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py @@ -46,9 +46,6 @@ def track( evaluation_context: Optional evaluation context event_details: Optional tracking event details """ - if not self._provider.client: - return - unleash_context = ( self._provider._build_unleash_context(evaluation_context) or {} ) diff --git a/providers/openfeature-provider-unleash/tests/test_provider.py b/providers/openfeature-provider-unleash/tests/test_provider.py index 34aba93c..a17dcb08 100644 --- a/providers/openfeature-provider-unleash/tests/test_provider.py +++ b/providers/openfeature-provider-unleash/tests/test_provider.py @@ -1,13 +1,13 @@ import uuid from unittest.mock import Mock, patch +from UnleashClient.cache import FileCache from UnleashClient.events import UnleashEventType, UnleashReadyEvent from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext from openfeature.provider import ProviderStatus - # Mock feature response for testing cache functionality MOCK_FEATURE_RESPONSE = { "version": 1, @@ -196,8 +196,6 @@ def test_unleash_provider_flag_metadata(): def test_unleash_provider_with_custom_cache(): """Test that UnleashProvider properly uses a custom cache with mocked features.""" - from UnleashClient.cache import FileCache - # Create a custom cache with mocked features custom_cache = FileCache("test-app") custom_cache.bootstrap_from_dict(MOCK_FEATURE_RESPONSE) diff --git a/uv.lock b/uv.lock index 30b58ee4..15ae9e7b 100644 --- a/uv.lock +++ b/uv.lock @@ -1015,7 +1015,8 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, - { name = "testcontainers" }, + { name = "testcontainers", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9.2'" }, + { name = "testcontainers", version = "4.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.2'" }, { name = "types-requests" }, ] From 6cccc1ed5863cd58a0ead8e949fd704e069fda62 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Mon, 29 Sep 2025 21:57:18 +0700 Subject: [PATCH 27/29] feat(provider): resolve_boolean_details honors default_value Signed-off-by: Kiki L Hakiem --- .../provider/unleash/flag_evaluation.py | 15 ++++++++++- .../tests/test_flag_evaluation.py | 27 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py index 6e59cfac..fddea1e1 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/flag_evaluation.py @@ -54,8 +54,13 @@ def resolve_boolean_details( Returns: FlagResolutionDetails with the resolved boolean value """ + context = self._provider._build_unleash_context(evaluation_context) - is_enabled = self._provider.client.is_enabled(flag_key, context=context) + is_enabled = self._provider.client.is_enabled( + flag_key, + context=context, + fallback_function=self._fallback_function(default_value), + ) return FlagResolutionDetails( value=is_enabled, @@ -224,3 +229,11 @@ def _parse_json(self, value: Any) -> Any: except json.JSONDecodeError as e: raise ParseError(f"Invalid JSON: {e}") from e return value + + def _fallback_function(self, default_value: bool) -> Callable: + """Default fallback function for Unleash provider.""" + + def fallback_function(feature_name: str, context: dict) -> bool: + return default_value + + return fallback_function diff --git a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py index 1a46f704..1723fa3b 100644 --- a/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py +++ b/providers/openfeature-provider-unleash/tests/test_flag_evaluation.py @@ -1,8 +1,9 @@ """Tests for flag evaluation functionality.""" -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest +from UnleashClient.cache import FileCache from openfeature.contrib.provider.unleash import UnleashProvider from openfeature.evaluation_context import EvaluationContext @@ -34,6 +35,29 @@ def test_resolve_boolean_details(): assert flag.reason == Reason.TARGETING_MATCH +def test_resolve_boolean_details_with_true_default(): + """Ensure default_value is used when engine returns None (fallback).""" + # Create a real client via the provider, but prevent network fetches + custom_cache = FileCache("test-app") + custom_cache.bootstrap_from_dict({}) + provider = UnleashProvider( + url="http://invalid-host.local", # won't be used since we disable fetch + app_name="test-app", + api_token="test-token", + fetch_toggles=False, + cache=custom_cache, + ) + + provider.initialize() + + try: + result = provider.resolve_boolean_details("test_flag", True) + assert result.value is True # uses the default value via fallback + assert result.reason == Reason.TARGETING_MATCH + finally: + provider.shutdown() + + @pytest.mark.parametrize( "method_name, payload_value, expected_value, default_value", [ @@ -105,6 +129,7 @@ def test_with_evaluation_context(): mock_client.is_enabled.assert_called_with( "test_flag", context={"userId": "user123", "email": "user@example.com", "country": "US"}, + fallback_function=ANY, ) provider.shutdown() From 3fc085af15e37cdab085550d25e8681d288dca20 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Fri, 3 Oct 2025 22:18:03 +0700 Subject: [PATCH 28/29] feat(provider): remove Unleash provider tracking Signed-off-by: Kiki L Hakiem --- .../openfeature-provider-unleash/README.md | 44 ---- .../contrib/provider/unleash/__init__.py | 13 +- .../contrib/provider/unleash/tracking.py | 71 ------- .../tests/test_tracking.py | 189 ------------------ 4 files changed, 4 insertions(+), 313 deletions(-) delete mode 100644 providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py delete mode 100644 providers/openfeature-provider-unleash/tests/test_tracking.py diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index d3cd486f..914595f1 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -84,41 +84,6 @@ provider.remove_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) Note: `ProviderEvent.PROVIDER_STALE` handlers can be registered but are not currently emitted by this provider. -### Tracking support - -The Unleash provider supports OpenFeature tracking for A/B testing and analytics: - -```python -from openfeature.evaluation_context import EvaluationContext - -# Basic tracking -provider.track("page_view") - -# Tracking with context -context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "country": "US"} -) -provider.track("button_click", context) - -# Tracking with event details -event_details = { - "value": 99.99, - "currency": "USD", - "category": "purchase" -} -provider.track("purchase_completed", event_details=event_details) - -# Tracking with both context and details -provider.track("conversion", context, event_details) -``` - -**Tracking features:** -- **Event Names**: Track user actions or application states -- **Evaluation Context**: Include user targeting information -- **Event Details**: Add numeric values and custom fields for analytics -- **Unleash Integration**: Uses UnleashClient's impression event infrastructure - ### Supported flag types This provider supports resolving the following types via the OpenFeature client: @@ -157,15 +122,6 @@ context = EvaluationContext(targeting_key="user123", attributes={"sessionId": "s variant = client.get_string_value("my-variant-flag", "default", context) print(f"Variant: {variant}") -# Track user actions for A/B testing -user_context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "plan": "premium"} -) - -provider.track("feature_experiment_view", user_context) -provider.track("conversion", user_context, {"value": 150.0, "currency": "USD"}) - # Shutdown when done provider.shutdown() ``` diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index 0337fe37..c8903a47 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -14,7 +14,6 @@ from .events import EventManager from .flag_evaluation import FlagEvaluator -from .tracking import Tracker __all__ = ["UnleashProvider"] @@ -61,7 +60,6 @@ def __init__( ProviderEvent.PROVIDER_CONFIGURATION_CHANGED: [], ProviderEvent.PROVIDER_STALE: [], } - self._tracking_manager = Tracker(self) self._event_manager = EventManager(self) self._flag_evaluator = FlagEvaluator(self) self.fetch_toggles = fetch_toggles @@ -147,17 +145,14 @@ def _unleash_event_callback(self, event: BaseEvent) -> None: def track( self, event_name: str, - evaluation_context: Optional[EvaluationContext] = None, event_details: Optional[dict] = None, ) -> None: - """Track user actions or application states using Unleash impression events. + """No-op tracking method. - Args: - event_name: The name of the tracking event - evaluation_context: Optional evaluation context - event_details: Optional tracking event details + Tracking is not implemented for this provider. Per the OpenFeature spec, + when the provider doesn't support tracking, client.track calls should no-op. """ - self._tracking_manager.track(event_name, evaluation_context, event_details) + return None def _build_unleash_context( self, evaluation_context: Optional[EvaluationContext] = None diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py deleted file mode 100644 index c6d9fdac..00000000 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/tracking.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tracking functionality for Unleash provider.""" - -import uuid -from typing import Any, Optional, Protocol - -from UnleashClient import UnleashClient -from UnleashClient.events import UnleashEvent, UnleashEventType - -from openfeature.evaluation_context import EvaluationContext - - -class UnleashProvider(Protocol): - """Protocol defining the interface needed by Tracker.""" - - @property - def client(self) -> Optional[UnleashClient]: ... - - def _build_unleash_context( - self, evaluation_context: Optional[EvaluationContext] = None - ) -> Optional[dict[str, Any]]: ... - - def _unleash_event_callback(self, event: Any) -> None: ... - - -class Tracker: - """Manages tracking functionality for the Unleash provider.""" - - def __init__(self, provider: UnleashProvider) -> None: - """Initialize the tracking manager. - - Args: - provider: The parent UnleashProvider instance - """ - self._provider = provider - - def track( - self, - event_name: str, - evaluation_context: Optional[EvaluationContext] = None, - event_details: Optional[dict] = None, - ) -> None: - """Track user actions or application states using Unleash impression events. - - Args: - event_name: The name of the tracking event - evaluation_context: Optional evaluation context - event_details: Optional tracking event details - """ - unleash_context = ( - self._provider._build_unleash_context(evaluation_context) or {} - ) - - if event_details: - unleash_context.update( - { - "tracking_value": event_details.get("value"), - "tracking_details": event_details, - } - ) - - tracking_event = UnleashEvent( - event_type=UnleashEventType.FEATURE_FLAG, - event_id=uuid.uuid4(), - context=unleash_context, - enabled=True, - feature_name=event_name, - variant="tracking_event", - ) - - if hasattr(self._provider, "_unleash_event_callback"): - self._provider._unleash_event_callback(tracking_event) diff --git a/providers/openfeature-provider-unleash/tests/test_tracking.py b/providers/openfeature-provider-unleash/tests/test_tracking.py deleted file mode 100644 index 3a9d54e2..00000000 --- a/providers/openfeature-provider-unleash/tests/test_tracking.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tests for tracking functionality.""" - -from unittest.mock import Mock, patch - -from openfeature.contrib.provider.unleash import UnleashProvider -from openfeature.evaluation_context import EvaluationContext - - -def test_track_basic(): - """Test basic tracking functionality.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - # Track a basic event - provider.track("user_action") - - assert mock_event_callback.call_count == 1 - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.feature_name == "user_action" - assert tracking_event.enabled is True - assert tracking_event.variant == "tracking_event" - - provider.shutdown() - - -def test_track_with_context(): - """Test tracking with evaluation context.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - context = EvaluationContext( - targeting_key="user123", - attributes={"email": "user@example.com", "role": "admin"}, - ) - - provider.track("page_view", context) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["userId"] == "user123" - assert tracking_event.context["email"] == "user@example.com" - assert tracking_event.context["role"] == "admin" - - provider.shutdown() - - -def test_track_with_event_details(): - """Test tracking with event details.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - event_details = {"value": 99.99, "currency": "USD", "category": "purchase"} - - provider.track("purchase_completed", event_details=event_details) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["tracking_value"] == 99.99 - assert tracking_event.context["tracking_details"] == event_details - - provider.shutdown() - - -def test_track_not_initialized(): - """Test tracking when provider is not initialized.""" - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - - # Should not raise any exception, just return - provider.track("test_event") - - -def test_track_edge_cases(): - """Test tracking edge cases.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - # Test event details without value - event_details = {"category": "test", "action": "view"} - provider.track("test_event", event_details=event_details) - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context["tracking_value"] is None - assert tracking_event.context["tracking_details"] == event_details - - provider.shutdown() - - -def test_track_none_context(): - """Test tracking with None context.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - provider.track("test_event", None) - - tracking_event = mock_event_callback.call_args[0][0] - assert tracking_event.context == {} - - provider.shutdown() - - -def test_track_none_event_details(): - """Test tracking with None event details.""" - mock_client = Mock() - mock_client.initialize_client.return_value = None - mock_event_callback = Mock() - - with patch( - "openfeature.contrib.provider.unleash.UnleashClient" - ) as mock_unleash_client: - mock_unleash_client.return_value = mock_client - - provider = UnleashProvider( - url="http://localhost:4242", app_name="test-app", api_token="test-token" - ) - provider.initialize() - - provider._unleash_event_callback = mock_event_callback - - provider.track("test_event", event_details=None) - - tracking_event = mock_event_callback.call_args[0][0] - assert "tracking_value" not in tracking_event.context - assert "tracking_details" not in tracking_event.context - - provider.shutdown() From 9ec7f03ee139a4227b6cc5c06b61b567458259f5 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Fri, 3 Oct 2025 22:44:45 +0700 Subject: [PATCH 29/29] feat(provider): remove deprecated environment argument Signed-off-by: Kiki L Hakiem --- providers/openfeature-provider-unleash/README.md | 2 -- .../src/openfeature/contrib/provider/unleash/__init__.py | 4 ---- 2 files changed, 6 deletions(-) diff --git a/providers/openfeature-provider-unleash/README.md b/providers/openfeature-provider-unleash/README.md index 914595f1..4e8a6498 100644 --- a/providers/openfeature-provider-unleash/README.md +++ b/providers/openfeature-provider-unleash/README.md @@ -27,7 +27,6 @@ provider = UnleashProvider( url="https://my-unleash-instance.com", app_name="my-python-app", api_token="my-api-token", - environment="development", # optional, defaults to "development" ) # Initialize the provider (required before use) @@ -41,7 +40,6 @@ api.set_provider(provider) - `url`: The URL of your Unleash server - `app_name`: The name of your application - `api_token`: The API token for authentication -- `environment`: The Unleash environment to connect to (default: `development`) ### Evaluation context mapping diff --git a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py index c8903a47..2c0635e9 100644 --- a/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py +++ b/providers/openfeature-provider-unleash/src/openfeature/contrib/provider/unleash/__init__.py @@ -24,7 +24,6 @@ def __init__( url: str, app_name: str, api_token: str, - environment: str = "development", fetch_toggles: bool = True, cache: Optional[BaseCache] = None, ) -> None: @@ -34,21 +33,18 @@ def __init__( url: The Unleash API URL app_name: The application name api_token: The API token for authentication - environment: The environment to connect to (default: "development") fetch_toggles: Whether to fetch toggles from server on initialization (default: True) cache: Optional cache implementation to use (default: UnleashClient's default) """ self.url = url self.app_name = app_name self.api_token = api_token - self.environment = environment self.cache = cache self._status = ProviderStatus.NOT_READY self.client: UnleashClient = UnleashClient( url=self.url, app_name=self.app_name, - environment=self.environment, custom_headers={"Authorization": self.api_token}, event_callback=self._unleash_event_callback, cache=self.cache,