├── .github ├── codeql-config.yml └── workflows │ ├── build.yml │ ├── lint-pr.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .release-please-manifest.json ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── openfeature ├── _backports │ ├── __init__.py │ └── strenum.py ├── _event_support.py ├── api.py ├── client.py ├── evaluation_context │ └── __init__.py ├── event.py ├── exception.py ├── flag_evaluation.py ├── hook │ ├── __init__.py │ └── _hook_support.py ├── immutable_dict │ ├── __init__.py │ └── mapping_proxy_type.py ├── provider │ ├── __init__.py │ ├── _registry.py │ ├── in_memory_provider.py │ ├── metadata.py │ ├── no_op_metadata.py │ ├── no_op_provider.py │ └── provider.py ├── py.typed ├── telemetry │ ├── __init__.py │ ├── attributes.py │ ├── body.py │ └── metadata.py ├── transaction_context │ ├── __init__.py │ ├── context_var_transaction_context_propagator.py │ ├── no_op_transaction_context_propagator.py │ └── transaction_context_propagator.py └── version.py ├── pyproject.toml ├── release-please-config.json ├── renovate.json └── tests ├── __init__.py ├── conftest.py ├── evaluation_context ├── __init__.py └── test_evaluation_context.py ├── features ├── __init__.py ├── data.py └── steps │ ├── __init__.py │ ├── flag_steps.py │ ├── hooks_steps.py │ ├── metadata_steps.py │ └── steps.py ├── hook ├── __init__.py ├── conftest.py └── test_hook_support.py ├── provider ├── __init__.py ├── test_in_memory_provider.py ├── test_no_op_provider.py └── test_provider_compatibility.py ├── telemetry ├── __init__.py └── test_evaluation_event.py ├── test_api.py ├── test_client.py ├── test_flag_evaluation.py └── test_transaction_context.py /.github/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL config" 2 | 3 | paths-ignore: 4 | - tests 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: "Build, lint, and test" 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 25 | 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | with: 29 | submodules: recursive 30 | 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | cache: "pip" 36 | allow-prereleases: true 37 | 38 | - name: Install hatch 39 | run: pip install hatch 40 | 41 | - name: Test with pytest 42 | run: hatch run cov 43 | 44 | - name: Run E2E tests with behave 45 | run: hatch run e2e 46 | 47 | - if: matrix.python-version == '3.13' 48 | name: Upload coverage to Codecov 49 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 50 | with: 51 | flags: unittests # optional 52 | name: coverage # optional 53 | fail_ci_if_error: true # optional (default = false) 54 | verbose: true # optional (default = false) 55 | token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} 56 | 57 | lint: 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 62 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 63 | with: 64 | python-version: "3.13" 65 | cache: "pip" 66 | 67 | - name: Run pre-commit 68 | uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 69 | 70 | sast: 71 | runs-on: ubuntu-latest 72 | permissions: 73 | actions: read 74 | contents: read 75 | security-events: write 76 | steps: 77 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 78 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 79 | with: 80 | python-version: "3.13" 81 | 82 | - name: Initialize CodeQL 83 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 84 | with: 85 | languages: python 86 | config-file: ./.github/codeql-config.yml 87 | 88 | - name: Perform CodeQL Analysis 89 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 90 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: "Lint PR" 7 | 8 | on: 9 | pull_request_target: 10 | types: 11 | - opened 12 | - edited 13 | - synchronize 14 | 15 | permissions: 16 | pull-requests: read 17 | 18 | jobs: 19 | main: 20 | name: Validate PR title 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Release 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | 13 | permissions: # added using https://github.com/step-security/secure-workflows 14 | contents: read 15 | 16 | jobs: 17 | release-please: 18 | permissions: 19 | contents: write # for googleapis/release-please-action to create release commit 20 | pull-requests: write # for googleapis/release-please-action to create release PR 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 25 | id: release 26 | with: 27 | command: manifest 28 | token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} 29 | default-branch: main 30 | signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>" 31 | outputs: 32 | release_created: ${{ steps.release.outputs.release_created }} 33 | release_tag_name: ${{ steps.release.outputs.tag_name }} 34 | 35 | release: 36 | runs-on: ubuntu-latest 37 | environment: publish 38 | permissions: 39 | # IMPORTANT: this permission is mandatory for trusted publishing to pypi 40 | id-token: write 41 | needs: release-please 42 | if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }} 43 | 44 | steps: 45 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 46 | 47 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 48 | with: 49 | python-version: '3.13' 50 | 51 | - name: Upgrade pip 52 | run: pip install --upgrade pip 53 | 54 | - name: Install hatch 55 | run: pip install hatch 56 | 57 | - name: Build a binary wheel and a source tarball 58 | run: hatch build 59 | 60 | - name: Publish a Python distribution to PyPI 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | *.egg-info/ 19 | .installed.cfg 20 | *.egg 21 | 22 | # Installer logs 23 | pip-log.txt 24 | pip-delete-this-directory.txt 25 | 26 | # Unit test / coverage reports 27 | .tox/ 28 | .coverage 29 | .cache 30 | nosetests.xml 31 | coverage.xml 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | .idea 41 | 42 | # Rope 43 | .ropeproject 44 | 45 | # Django stuff: 46 | *.log 47 | *.pot 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | 52 | # Virtual env directories 53 | .venv 54 | tests/features/*.feature 55 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "spec"] 2 | path = spec 3 | url = https://github.com/open-feature/spec.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [pre-commit] 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.11.13 5 | hooks: 6 | - id: ruff 7 | args: [--fix] 8 | - id: ruff-format 9 | 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: check-toml 14 | - id: check-yaml 15 | - id: trailing-whitespace 16 | - id: check-merge-conflict 17 | 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v1.16.0 20 | hooks: 21 | - id: mypy 22 | files: openfeature 23 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"0.8.1"} -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @open-feature/sdk-python-maintainers @open-feature/maintainers 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development 4 | 5 | ### System Requirements 6 | 7 | Python 3.9 and above are required. 8 | 9 | ### Target version(s) 10 | 11 | Python 3.9 and above are supported by the SDK. 12 | 13 | ### Installation and Dependencies 14 | 15 | We use [Hatch](https://hatch.pypa.io/) to manage the project. 16 | 17 | To install Hatch, just run `pip install hatch`. 18 | 19 | You will also need to set up the `pre-commit` hooks. 20 | Run `pre-commit install` in the root directory of the repository. 21 | If you don't have `pre-commit` installed, you can install it with `pip install pre-commit`. 22 | 23 | ### Testing 24 | 25 | Run tests with `hatch run test`. 26 | 27 | We use `pytest` for our unit testing, making use of `parametrized` to inject cases at scale. 28 | 29 | ### Integration tests 30 | 31 | These are planned once the SDK has been stabilized and a Flagd provider implemented. At that point, we will utilize the [gherkin integration tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) to validate against a live, seeded Flagd instance. 32 | 33 | ### Packaging 34 | 35 | We publish to the PyPI repository, where you can find this package at [openfeature-sdk](https://pypi.org/project/openfeature-sdk/). 36 | 37 | ## Pull Request 38 | 39 | All contributions to the OpenFeature project are welcome via GitHub pull requests. 40 | 41 | To create a new PR, you will need to first fork the GitHub repository and clone upstream. 42 | 43 | ```bash 44 | git clone https://github.com/open-feature/python-sdk.git openfeature-python-sdk 45 | ``` 46 | 47 | Navigate to the repository folder 48 | 49 | ```bash 50 | cd openfeature-python-sdk 51 | ``` 52 | 53 | Add your fork as an origin 54 | 55 | ```bash 56 | git remote add fork https://github.com/YOUR_GITHUB_USERNAME/python-sdk.git 57 | ``` 58 | 59 | Ensure your development environment is all set up by building and testing 60 | 61 | ```bash 62 | hatch run test 63 | ``` 64 | 65 | To start working on a new feature or bugfix, create a new branch and start working on it. 66 | 67 | ```bash 68 | git checkout -b feat/NAME_OF_FEATURE 69 | # Make your changes 70 | git commit 71 | git push fork feat/NAME_OF_FEATURE 72 | ``` 73 | 74 | Open a pull request against the main python-sdk repository. 75 | 76 | ### How to Receive Comments 77 | 78 | - If the PR is not ready for review, please mark it as 79 | [`draft`](https://github.blog/2019-02-14-introducing-draft-pull-requests/). 80 | - Make sure all required CI checks are clear. 81 | - Submit small, focused PRs addressing a single concern/issue. 82 | - Make sure the PR title reflects the contribution. 83 | - Write a summary that explains the change. 84 | - Include usage examples in the summary, where applicable. 85 | 86 | ### How to Get PRs Merged 87 | 88 | A PR is considered to be **ready to merge** when: 89 | 90 | - Major feedback is resolved. 91 | - Urgent fix can take exception as long as it has been actively communicated. 92 | 93 | Any Maintainer can merge the PR once it is **ready to merge**. Note, that some 94 | PRs may not be merged immediately if the repo is in the process of a release and 95 | the maintainers decided to defer the PR to the next release train. 96 | 97 | If a PR has been stuck (e.g. there are lots of debates and people couldn't agree 98 | on each other), the owner should try to get people aligned by: 99 | 100 | - Consolidating the perspectives and putting a summary in the PR. It is 101 | recommended to add a link into the PR description, which points to a comment 102 | with a summary in the PR conversation. 103 | - Tagging domain experts (by looking at the change history) in the PR asking 104 | for suggestion. 105 | - Reaching out to more people on the [CNCF OpenFeature Slack channel](https://cloud-native.slack.com/archives/C0344AANLA1). 106 | - Stepping back to see if it makes sense to narrow down the scope of the PR or 107 | split it up. 108 | - If none of the above worked and the PR has been stuck for more than 2 weeks, 109 | the owner should bring it to the OpenFeatures [meeting](README.md#contributing). 110 | 111 | ## Design Choices 112 | 113 | As with other OpenFeature SDKs, python-sdk follows the 114 | [openfeature-specification](https://github.com/open-feature/spec). 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 | 6 | OpenFeature Logo 7 | 8 |

9 | 10 |

OpenFeature Python SDK

11 | 12 | 13 | 14 |

15 | 16 | 17 | Specification 18 | 19 | 20 | 21 | 22 | 23 | Latest version 24 | 25 | 26 | 27 |
28 | 29 | Build status 30 | 31 | 32 | 33 | Codecov 34 | 35 | 36 | 37 | Min python version 38 | 39 | 40 | 41 | Repo status 42 | 43 |

44 | 45 | 46 | [OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool. 47 | 48 | 49 | 50 | ## 🚀 Quick start 51 | 52 | ### Requirements 53 | 54 | - Python 3.9+ 55 | 56 | ### Install 57 | 58 | 59 | 60 | #### Pip install 61 | 62 | ```bash 63 | pip install openfeature-sdk==0.8.1 64 | ``` 65 | 66 | #### requirements.txt 67 | 68 | ```bash 69 | openfeature-sdk==0.8.1 70 | ``` 71 | 72 | ```python 73 | pip install -r requirements.txt 74 | ``` 75 | 76 | 77 | 78 | ### Usage 79 | 80 | ```python 81 | from openfeature import api 82 | from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider 83 | 84 | # flags defined in memory 85 | my_flags = { 86 | "v2_enabled": InMemoryFlag("on", {"on": True, "off": False}) 87 | } 88 | 89 | # configure a provider 90 | api.set_provider(InMemoryProvider(my_flags)) 91 | 92 | # create a client 93 | client = api.get_client() 94 | 95 | # get a bool flag value 96 | flag_value = client.get_boolean_value("v2_enabled", False) 97 | print("Value: " + str(flag_value)) 98 | ``` 99 | 100 | ## 🌟 Features 101 | 102 | | Status | Features | Description | 103 | |--------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| 104 | | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | 105 | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | 106 | | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | 107 | | ✅ | [Logging](#logging) | Integrate with popular logging packages. | 108 | | ✅ | [Domains](#domains) | Logically bind clients with providers. | 109 | | ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | 110 | | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | 111 | | ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | 112 | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | 113 | 114 | Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ 115 | 116 | ### Providers 117 | 118 | [Providers](https://openfeature.dev/docs/reference/concepts/provider) are an abstraction between a flag management system and the OpenFeature SDK. 119 | Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Provider&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Python) for a complete list of available providers. 120 | If the provider you're looking for hasn't been created yet, see the [develop a provider](#develop-a-provider) section to learn how to build it yourself. 121 | 122 | Once you've added a provider as a dependency, it can be registered with OpenFeature like this: 123 | 124 | ```python 125 | from openfeature import api 126 | from openfeature.provider.no_op_provider import NoOpProvider 127 | 128 | api.set_provider(NoOpProvider()) 129 | open_feature_client = api.get_client() 130 | ``` 131 | 132 | In some situations, it may be beneficial to register multiple providers in the same application. 133 | This is possible using [domains](#domains), which is covered in more detail below. 134 | 135 | ### Targeting 136 | 137 | Sometimes, the value of a flag must consider some dynamic criteria about the application or user, such as the user's location, IP, email address, or the server's location. 138 | In OpenFeature, we refer to this as [targeting](https://openfeature.dev/specification/glossary#targeting). 139 | If the flag management system you're using supports targeting, you can provide the input data using the [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). 140 | 141 | ```python 142 | from openfeature.api import ( 143 | get_client, 144 | get_provider, 145 | set_provider, 146 | get_evaluation_context, 147 | set_evaluation_context, 148 | ) 149 | 150 | global_context = EvaluationContext( 151 | targeting_key="targeting_key1", attributes={"application": "value1"} 152 | ) 153 | request_context = EvaluationContext( 154 | targeting_key="targeting_key2", attributes={"email": request.form['email']} 155 | ) 156 | 157 | ## set global context 158 | set_evaluation_context(global_context) 159 | 160 | # merge second context 161 | client = get_client(name="No-op Provider") 162 | client.get_string_value("email", "fallback", request_context) 163 | ``` 164 | 165 | ### Hooks 166 | 167 | [Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle. 168 | Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Python) for a complete list of available hooks. 169 | If the hook you're looking for hasn't been created yet, see the [develop a hook](#develop-a-hook) section to learn how to build it yourself. 170 | 171 | Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level. 172 | 173 | ```python 174 | from openfeature.api import add_hooks 175 | from openfeature.flag_evaluation import FlagEvaluationOptions 176 | 177 | # set global hooks at the API-level 178 | add_hooks([MyHook()]) 179 | 180 | # or configure them in the client 181 | client = OpenFeatureClient() 182 | client.add_hooks([MyHook()]) 183 | 184 | # or at the invocation-level 185 | options = FlagEvaluationOptions(hooks=[MyHook()]) 186 | client.get_boolean_flag("my-flag", False, flag_evaluation_options=options) 187 | ``` 188 | 189 | ### Logging 190 | 191 | The OpenFeature SDK logs to the `openfeature` logger using the `logging` package from the Python Standard Library. 192 | 193 | ### Domains 194 | 195 | Clients can be assigned to a domain. 196 | A domain is a logical identifier which can be used to associate clients with a particular provider. 197 | If a domain has no associated provider, the global provider is used. 198 | 199 | ```python 200 | from openfeature import api 201 | 202 | # Registering the default provider 203 | api.set_provider(MyProvider()); 204 | # Registering a provider to a domain 205 | api.set_provider(MyProvider(), "my-domain"); 206 | 207 | # A client bound to the default provider 208 | default_client = api.get_client(); 209 | # A client bound to the MyProvider provider 210 | domain_scoped_client = api.get_client("my-domain"); 211 | ``` 212 | 213 | Domains can be defined on a provider during registration. 214 | For more details, please refer to the [providers](#providers) section. 215 | 216 | ### Eventing 217 | 218 | Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions. Initialization events (PROVIDER_READY on success, PROVIDER_ERROR on failure) are dispatched for every provider. Some providers support additional events, such as PROVIDER_CONFIGURATION_CHANGED. 219 | 220 | Please refer to the documentation of the provider you're using to see what events are supported. 221 | 222 | ```python 223 | from openfeature import api 224 | from openfeature.provider import ProviderEvent 225 | 226 | def on_provider_ready(event_details: EventDetails): 227 | print(f"Provider {event_details.provider_name} is ready") 228 | 229 | api.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) 230 | 231 | client = api.get_client() 232 | 233 | def on_provider_ready(event_details: EventDetails): 234 | print(f"Provider {event_details.provider_name} is ready") 235 | 236 | client.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) 237 | ``` 238 | 239 | ### Transaction Context Propagation 240 | 241 | Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP). 242 | Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread). 243 | 244 | You can implement a different transaction context propagator by implementing the `TransactionContextPropagator` class exported by the OpenFeature SDK. 245 | In most cases you can use `ContextVarsTransactionContextPropagator` as it works for `threads` and `asyncio` using [Context Variables](https://peps.python.org/pep-0567/). 246 | 247 | The following example shows a **multithreaded** Flask application using transaction context propagation to propagate the request ip and user id into request scoped transaction context. 248 | 249 | ```python 250 | from flask import Flask, request 251 | from openfeature import api 252 | from openfeature.evaluation_context import EvaluationContext 253 | from openfeature.transaction_context import ContextVarsTransactionContextPropagator 254 | 255 | # Initialize the Flask app 256 | app = Flask(__name__) 257 | 258 | # Set the transaction context propagator 259 | api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) 260 | 261 | # Middleware to set the transaction context 262 | # You can call api.set_transaction_context anywhere you have information, 263 | # you want to have available in the code-paths below the current one. 264 | @app.before_request 265 | def set_request_transaction_context(): 266 | ip = request.headers.get("X-Forwarded-For", request.remote_addr) 267 | user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header 268 | evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip}) 269 | api.set_transaction_context(evaluation_context) 270 | 271 | def create_response() -> str: 272 | # This method can be anywhere in our code. 273 | # The feature flag evaluation will automatically contain the transaction context merged with other context 274 | new_response = api.get_client().get_string_value("response-message", "Hello User!") 275 | return f"Message from server: {new_response}" 276 | 277 | # Example route where we use the transaction context 278 | @app.route('/greeting') 279 | def some_endpoint(): 280 | return create_response() 281 | ``` 282 | 283 | This also works for asyncio based implementations e.g. FastApi as seen in the following example: 284 | 285 | ```python 286 | from fastapi import FastAPI, Request 287 | from openfeature import api 288 | from openfeature.evaluation_context import EvaluationContext 289 | from openfeature.transaction_context import ContextVarsTransactionContextPropagator 290 | 291 | # Initialize the FastAPI app 292 | app = FastAPI() 293 | 294 | # Set the transaction context propagator 295 | api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) 296 | 297 | # Middleware to set the transaction context 298 | @app.middleware("http") 299 | async def set_request_transaction_context(request: Request, call_next): 300 | ip = request.headers.get("X-Forwarded-For", request.client.host) 301 | user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header 302 | evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip}) 303 | api.set_transaction_context(evaluation_context) 304 | response = await call_next(request) 305 | return response 306 | 307 | def create_response() -> str: 308 | # This method can be located anywhere in our code. 309 | # The feature flag evaluation will automatically include the transaction context merged with other context. 310 | new_response = api.get_client().get_string_value("response-message", "Hello User!") 311 | return f"Message from server: {new_response}" 312 | 313 | # Example route where we use the transaction context 314 | @app.get('/greeting') 315 | async def some_endpoint(): 316 | return create_response() 317 | ``` 318 | 319 | ### Asynchronous Feature Retrieval 320 | 321 | The OpenFeature API supports asynchronous calls, enabling non-blocking feature evaluations for improved performance, especially useful in concurrent or latency-sensitive scenarios. If a provider *hasn't* implemented asynchronous calls, the client can still be used asynchronously, but calls will be blocking (synchronous). 322 | 323 | ```python 324 | import asyncio 325 | from openfeature import api 326 | from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider 327 | 328 | my_flags = { "v2_enabled": InMemoryFlag("on", {"on": True, "off": False}) } 329 | api.set_provider(InMemoryProvider(my_flags)) 330 | client = api.get_client() 331 | flag_value = await client.get_boolean_value_async("v2_enabled", False) # API calls are suffixed by _async 332 | 333 | print("Value: " + str(flag_value)) 334 | ``` 335 | 336 | See the [develop a provider](#develop-a-provider) for how to support asynchronous functionality in providers. 337 | 338 | ### Shutdown 339 | 340 | The OpenFeature API provides a shutdown function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down. 341 | 342 | ```python 343 | from openfeature import api 344 | 345 | api.shutdown() 346 | ``` 347 | 348 | ## Extending 349 | 350 | ### Develop a provider 351 | 352 | To develop a provider, you need to create a new project and include the OpenFeature SDK as a dependency. 353 | This can be a new repository or included in [the existing contrib repository](https://github.com/open-feature/python-sdk-contrib) available under the OpenFeature organization. 354 | You’ll then need to write the provider by implementing the `AbstractProvider` class exported by the OpenFeature SDK. 355 | 356 | ```python 357 | from typing import List, Optional, Union 358 | 359 | from openfeature.evaluation_context import EvaluationContext 360 | from openfeature.flag_evaluation import FlagResolutionDetails 361 | from openfeature.hook import Hook 362 | from openfeature.provider import AbstractProvider, Metadata 363 | 364 | class MyProvider(AbstractProvider): 365 | def get_metadata(self) -> Metadata: 366 | ... 367 | 368 | def get_provider_hooks(self) -> List[Hook]: 369 | return [] 370 | 371 | def resolve_boolean_details( 372 | self, 373 | flag_key: str, 374 | default_value: bool, 375 | evaluation_context: Optional[EvaluationContext] = None, 376 | ) -> FlagResolutionDetails[bool]: 377 | ... 378 | 379 | def resolve_string_details( 380 | self, 381 | flag_key: str, 382 | default_value: str, 383 | evaluation_context: Optional[EvaluationContext] = None, 384 | ) -> FlagResolutionDetails[str]: 385 | ... 386 | 387 | def resolve_integer_details( 388 | self, 389 | flag_key: str, 390 | default_value: int, 391 | evaluation_context: Optional[EvaluationContext] = None, 392 | ) -> FlagResolutionDetails[int]: 393 | ... 394 | 395 | def resolve_float_details( 396 | self, 397 | flag_key: str, 398 | default_value: float, 399 | evaluation_context: Optional[EvaluationContext] = None, 400 | ) -> FlagResolutionDetails[float]: 401 | ... 402 | 403 | def resolve_object_details( 404 | self, 405 | flag_key: str, 406 | default_value: Union[dict, list], 407 | evaluation_context: Optional[EvaluationContext] = None, 408 | ) -> FlagResolutionDetails[Union[dict, list]]: 409 | ... 410 | ``` 411 | 412 | Providers can also be extended to support async functionality. 413 | To support add asynchronous calls to a provider: 414 | 415 | - Implement the `AbstractProvider` as shown above. 416 | - Define asynchronous calls for each data type. 417 | 418 | ```python 419 | class MyProvider(AbstractProvider): 420 | ... 421 | async def resolve_boolean_details_async( 422 | self, 423 | flag_key: str, 424 | default_value: bool, 425 | evaluation_context: Optional[EvaluationContext] = None, 426 | ) -> FlagResolutionDetails[bool]: 427 | ... 428 | 429 | async def resolve_string_details_async( 430 | self, 431 | flag_key: str, 432 | default_value: str, 433 | evaluation_context: Optional[EvaluationContext] = None, 434 | ) -> FlagResolutionDetails[str]: 435 | ... 436 | 437 | async def resolve_integer_details_async( 438 | self, 439 | flag_key: str, 440 | default_value: int, 441 | evaluation_context: Optional[EvaluationContext] = None, 442 | ) -> FlagResolutionDetails[int]: 443 | ... 444 | 445 | async def resolve_float_details_async( 446 | self, 447 | flag_key: str, 448 | default_value: float, 449 | evaluation_context: Optional[EvaluationContext] = None, 450 | ) -> FlagResolutionDetails[float]: 451 | ... 452 | 453 | async def resolve_object_details_async( 454 | self, 455 | flag_key: str, 456 | default_value: Union[dict, list], 457 | evaluation_context: Optional[EvaluationContext] = None, 458 | ) -> FlagResolutionDetails[Union[dict, list]]: 459 | ... 460 | 461 | ``` 462 | 463 | > Built a new provider? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=provider&projects=&template=document-provider.yaml&title=%5BProvider%5D%3A+) so we can add it to the docs! 464 | 465 | ### Develop a hook 466 | 467 | To develop a hook, you need to create a new project and include the OpenFeature SDK as a dependency. 468 | This can be a new repository or included in [the existing contrib repository](https://github.com/open-feature/python-sdk-contrib) available under the OpenFeature organization. 469 | Implement your own hook by creating a hook that inherits from the `Hook` class. 470 | Any of the evaluation life-cycle stages (`before`/`after`/`error`/`finally_after`) can be override to add the desired business logic. 471 | 472 | ```python 473 | from openfeature.hook import Hook 474 | 475 | class MyHook(Hook): 476 | def after(self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict): 477 | print("This runs after the flag has been evaluated") 478 | 479 | ``` 480 | 481 | > Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! 482 | 483 | 484 | 485 | ## ⭐️ Support the project 486 | 487 | - Give this repo a ⭐️! 488 | - Follow us on social media: 489 | - Twitter: [@openfeature](https://twitter.com/openfeature) 490 | - LinkedIn: [OpenFeature](https://www.linkedin.com/company/openfeature/) 491 | - Join us on [Slack](https://cloud-native.slack.com/archives/C0344AANLA1) 492 | - For more, check out our [community page](https://openfeature.dev/community/) 493 | 494 | ## 🤝 Contributing 495 | 496 | Interested in contributing? Great, we'd love your help! To get started, take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide. 497 | 498 | ### Thanks to everyone who has already contributed 499 | 500 | 501 | Pictures of the folks who have contributed to the project 502 | 503 | 504 | Made with [contrib.rocks](https://contrib.rocks). 505 | 506 | 507 | -------------------------------------------------------------------------------- /openfeature/_backports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/openfeature/_backports/__init__.py -------------------------------------------------------------------------------- /openfeature/_backports/strenum.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3, 11): 4 | # re-export needed for type checking 5 | from enum import StrEnum as StrEnum # noqa: PLC0414 6 | else: 7 | from enum import Enum 8 | 9 | class StrEnum(str, Enum): 10 | """ 11 | Backport StrEnum for Python <3.11 12 | """ 13 | 14 | pass 15 | -------------------------------------------------------------------------------- /openfeature/_event_support.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | from collections import defaultdict 5 | from typing import TYPE_CHECKING 6 | 7 | from openfeature.event import ( 8 | EventDetails, 9 | EventHandler, 10 | ProviderEvent, 11 | ProviderEventDetails, 12 | ) 13 | from openfeature.provider import FeatureProvider, ProviderStatus 14 | 15 | if TYPE_CHECKING: 16 | from openfeature.client import OpenFeatureClient 17 | 18 | 19 | _global_lock = threading.RLock() 20 | _global_handlers: dict[ProviderEvent, list[EventHandler]] = defaultdict(list) 21 | 22 | _client_lock = threading.RLock() 23 | _client_handlers: dict[OpenFeatureClient, dict[ProviderEvent, list[EventHandler]]] = ( 24 | defaultdict(lambda: defaultdict(list)) 25 | ) 26 | 27 | 28 | def run_client_handlers( 29 | client: OpenFeatureClient, event: ProviderEvent, details: EventDetails 30 | ) -> None: 31 | with _client_lock: 32 | for handler in _client_handlers[client][event]: 33 | handler(details) 34 | 35 | 36 | def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None: 37 | with _global_lock: 38 | for handler in _global_handlers[event]: 39 | handler(details) 40 | 41 | 42 | def add_client_handler( 43 | client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler 44 | ) -> None: 45 | with _client_lock: 46 | handlers = _client_handlers[client][event] 47 | handlers.append(handler) 48 | 49 | _run_immediate_handler(client, event, handler) 50 | 51 | 52 | def remove_client_handler( 53 | client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler 54 | ) -> None: 55 | with _client_lock: 56 | handlers = _client_handlers[client][event] 57 | handlers.remove(handler) 58 | 59 | 60 | def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None: 61 | with _global_lock: 62 | _global_handlers[event].append(handler) 63 | 64 | from openfeature.api import get_client 65 | 66 | _run_immediate_handler(get_client(), event, handler) 67 | 68 | 69 | def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None: 70 | with _global_lock: 71 | _global_handlers[event].remove(handler) 72 | 73 | 74 | def run_handlers_for_provider( 75 | provider: FeatureProvider, 76 | event: ProviderEvent, 77 | provider_details: ProviderEventDetails, 78 | ) -> None: 79 | details = EventDetails.from_provider_event_details( 80 | provider.get_metadata().name, provider_details 81 | ) 82 | # run the global handlers 83 | run_global_handlers(event, details) 84 | # run the handlers for clients associated to this provider 85 | with _client_lock: 86 | for client in _client_handlers: 87 | if client.provider == provider: 88 | run_client_handlers(client, event, details) 89 | 90 | 91 | def _run_immediate_handler( 92 | client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler 93 | ) -> None: 94 | status_to_event = { 95 | ProviderStatus.READY: ProviderEvent.PROVIDER_READY, 96 | ProviderStatus.ERROR: ProviderEvent.PROVIDER_ERROR, 97 | ProviderStatus.FATAL: ProviderEvent.PROVIDER_ERROR, 98 | ProviderStatus.STALE: ProviderEvent.PROVIDER_STALE, 99 | } 100 | if event == status_to_event.get(client.get_provider_status()): 101 | handler(EventDetails(provider_name=client.provider.get_metadata().name)) 102 | 103 | 104 | def clear() -> None: 105 | with _global_lock: 106 | _global_handlers.clear() 107 | with _client_lock: 108 | _client_handlers.clear() 109 | -------------------------------------------------------------------------------- /openfeature/api.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from openfeature import _event_support 4 | from openfeature.client import OpenFeatureClient 5 | from openfeature.evaluation_context import ( 6 | get_evaluation_context, 7 | set_evaluation_context, 8 | ) 9 | from openfeature.event import ( 10 | EventHandler, 11 | ProviderEvent, 12 | ) 13 | from openfeature.hook import add_hooks, clear_hooks, get_hooks 14 | from openfeature.provider import FeatureProvider 15 | from openfeature.provider._registry import provider_registry 16 | from openfeature.provider.metadata import Metadata 17 | from openfeature.transaction_context import ( 18 | get_transaction_context, 19 | set_transaction_context, 20 | set_transaction_context_propagator, 21 | ) 22 | 23 | __all__ = [ 24 | "add_handler", 25 | "add_hooks", 26 | "clear_hooks", 27 | "clear_providers", 28 | "get_client", 29 | "get_evaluation_context", 30 | "get_hooks", 31 | "get_provider_metadata", 32 | "get_transaction_context", 33 | "remove_handler", 34 | "set_evaluation_context", 35 | "set_provider", 36 | "set_transaction_context", 37 | "set_transaction_context_propagator", 38 | "shutdown", 39 | ] 40 | 41 | 42 | def get_client( 43 | domain: typing.Optional[str] = None, version: typing.Optional[str] = None 44 | ) -> OpenFeatureClient: 45 | return OpenFeatureClient(domain=domain, version=version) 46 | 47 | 48 | def set_provider( 49 | provider: FeatureProvider, domain: typing.Optional[str] = None 50 | ) -> None: 51 | if domain is None: 52 | provider_registry.set_default_provider(provider) 53 | else: 54 | provider_registry.set_provider(domain, provider) 55 | 56 | 57 | def clear_providers() -> None: 58 | provider_registry.clear_providers() 59 | _event_support.clear() 60 | 61 | 62 | def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata: 63 | return provider_registry.get_provider(domain).get_metadata() 64 | 65 | 66 | def shutdown() -> None: 67 | provider_registry.shutdown() 68 | 69 | 70 | def add_handler(event: ProviderEvent, handler: EventHandler) -> None: 71 | _event_support.add_global_handler(event, handler) 72 | 73 | 74 | def remove_handler(event: ProviderEvent, handler: EventHandler) -> None: 75 | _event_support.remove_global_handler(event, handler) 76 | -------------------------------------------------------------------------------- /openfeature/evaluation_context/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Sequence 5 | from dataclasses import dataclass, field 6 | from datetime import datetime 7 | 8 | from openfeature.exception import GeneralError 9 | 10 | __all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] 11 | 12 | # https://openfeature.dev/specification/sections/evaluation-context#requirement-312 13 | EvaluationContextAttributes = typing.Mapping[ 14 | str, 15 | typing.Union[ 16 | bool, 17 | int, 18 | float, 19 | str, 20 | datetime, 21 | Sequence["EvaluationContextAttributes"], 22 | typing.Mapping[str, "EvaluationContextAttributes"], 23 | ], 24 | ] 25 | 26 | 27 | @dataclass 28 | class EvaluationContext: 29 | targeting_key: typing.Optional[str] = None 30 | attributes: EvaluationContextAttributes = field(default_factory=dict) 31 | 32 | def merge(self, ctx2: EvaluationContext) -> EvaluationContext: 33 | if not (self and ctx2): 34 | return self or ctx2 35 | 36 | attributes = {**self.attributes, **ctx2.attributes} 37 | targeting_key = ctx2.targeting_key or self.targeting_key 38 | 39 | return EvaluationContext(targeting_key=targeting_key, attributes=attributes) 40 | 41 | 42 | def get_evaluation_context() -> EvaluationContext: 43 | return _evaluation_context 44 | 45 | 46 | def set_evaluation_context(evaluation_context: EvaluationContext) -> None: 47 | global _evaluation_context 48 | if evaluation_context is None: 49 | raise GeneralError(error_message="No api level evaluation context") 50 | _evaluation_context = evaluation_context 51 | 52 | 53 | # need to be at the bottom, because of the definition order 54 | _evaluation_context = EvaluationContext() 55 | -------------------------------------------------------------------------------- /openfeature/event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from enum import Enum 5 | from typing import Callable, Optional, Union 6 | 7 | from openfeature.exception import ErrorCode 8 | 9 | __all__ = ["EventDetails", "EventHandler", "ProviderEvent", "ProviderEventDetails"] 10 | 11 | 12 | class ProviderEvent(Enum): 13 | PROVIDER_READY = "PROVIDER_READY" 14 | PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED" 15 | PROVIDER_ERROR = "PROVIDER_ERROR" 16 | PROVIDER_STALE = "PROVIDER_STALE" 17 | 18 | 19 | @dataclass 20 | class ProviderEventDetails: 21 | flags_changed: Optional[list[str]] = None 22 | message: Optional[str] = None 23 | error_code: Optional[ErrorCode] = None 24 | metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict) 25 | 26 | 27 | @dataclass 28 | class EventDetails(ProviderEventDetails): 29 | provider_name: str = "" 30 | flags_changed: Optional[list[str]] = None 31 | message: Optional[str] = None 32 | error_code: Optional[ErrorCode] = None 33 | metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict) 34 | 35 | @classmethod 36 | def from_provider_event_details( 37 | cls, provider_name: str, details: ProviderEventDetails 38 | ) -> EventDetails: 39 | return cls( 40 | provider_name=provider_name, 41 | flags_changed=details.flags_changed, 42 | message=details.message, 43 | error_code=details.error_code, 44 | metadata=details.metadata, 45 | ) 46 | 47 | 48 | EventHandler = Callable[[EventDetails], None] 49 | -------------------------------------------------------------------------------- /openfeature/exception.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Mapping 5 | 6 | from openfeature._backports.strenum import StrEnum 7 | 8 | __all__ = [ 9 | "ErrorCode", 10 | "FlagNotFoundError", 11 | "GeneralError", 12 | "InvalidContextError", 13 | "OpenFeatureError", 14 | "ParseError", 15 | "ProviderFatalError", 16 | "ProviderNotReadyError", 17 | "TargetingKeyMissingError", 18 | "TypeMismatchError", 19 | ] 20 | 21 | 22 | class OpenFeatureError(Exception): 23 | """ 24 | A generic open feature exception, this exception should not be raised. Instead 25 | the more specific exceptions extending this one should be used. 26 | """ 27 | 28 | def __init__( 29 | self, error_code: ErrorCode, error_message: typing.Optional[str] = None 30 | ): 31 | """ 32 | Constructor for the generic OpenFeatureError. 33 | @param error_message: an optional string message representing why the 34 | error has been raised 35 | @param error_code: the ErrorCode string enum value for the type of error 36 | """ 37 | self.error_message = error_message 38 | self.error_code = error_code 39 | 40 | 41 | class ProviderNotReadyError(OpenFeatureError): 42 | """ 43 | This exception should be raised when the provider is not ready to be used. 44 | """ 45 | 46 | def __init__(self, error_message: typing.Optional[str] = None): 47 | """ 48 | Constructor for the ProviderNotReadyError. The error code for this type of 49 | exception is ErrorCode.PROVIDER_NOT_READY. 50 | @param error_message: a string message representing why the error has been 51 | raised 52 | """ 53 | super().__init__(ErrorCode.PROVIDER_NOT_READY, error_message) 54 | 55 | 56 | class ProviderFatalError(OpenFeatureError): 57 | """ 58 | This exception should be raised when the provider encounters a fatal error. 59 | """ 60 | 61 | def __init__(self, error_message: typing.Optional[str] = None): 62 | """ 63 | Constructor for the ProviderFatalError. The error code for this type of 64 | exception is ErrorCode.PROVIDER_FATAL. 65 | @param error_message: a string message representing why the error has been 66 | raised 67 | """ 68 | super().__init__(ErrorCode.PROVIDER_FATAL, error_message) 69 | 70 | 71 | class FlagNotFoundError(OpenFeatureError): 72 | """ 73 | This exception should be raised when the provider cannot find a flag with the 74 | key provided by the user. 75 | """ 76 | 77 | def __init__(self, error_message: typing.Optional[str] = None): 78 | """ 79 | Constructor for the FlagNotFoundError. The error code for 80 | this type of exception is ErrorCode.FLAG_NOT_FOUND. 81 | @param error_message: an optional string message representing 82 | why the error has been raised 83 | """ 84 | super().__init__(ErrorCode.FLAG_NOT_FOUND, error_message) 85 | 86 | 87 | class GeneralError(OpenFeatureError): 88 | """ 89 | This exception should be raised when the for an exception within the open 90 | feature python sdk. 91 | """ 92 | 93 | def __init__(self, error_message: typing.Optional[str] = None): 94 | """ 95 | Constructor for the GeneralError. The error code for this type of exception 96 | is ErrorCode.GENERAL. 97 | @param error_message: an optional string message representing why the error 98 | has been raised 99 | """ 100 | super().__init__(ErrorCode.GENERAL, error_message) 101 | 102 | 103 | class ParseError(OpenFeatureError): 104 | """ 105 | This exception should be raised when the flag returned by the provider cannot 106 | be parsed into a FlagEvaluationDetails object. 107 | """ 108 | 109 | def __init__(self, error_message: typing.Optional[str] = None): 110 | """ 111 | Constructor for the ParseError. The error code for this type of exception 112 | is ErrorCode.PARSE_ERROR. 113 | @param error_message: an optional string message representing why the 114 | error has been raised 115 | """ 116 | super().__init__(ErrorCode.PARSE_ERROR, error_message) 117 | 118 | 119 | class TypeMismatchError(OpenFeatureError): 120 | """ 121 | This exception should be raised when the flag returned by the provider does 122 | not match the type requested by the user. 123 | """ 124 | 125 | def __init__(self, error_message: typing.Optional[str] = None): 126 | """ 127 | Constructor for the TypeMismatchError. The error code for this type of 128 | exception is ErrorCode.TYPE_MISMATCH. 129 | @param error_message: an optional string message representing why the 130 | error has been raised 131 | """ 132 | super().__init__(ErrorCode.TYPE_MISMATCH, error_message) 133 | 134 | 135 | class TargetingKeyMissingError(OpenFeatureError): 136 | """ 137 | This exception should be raised when the provider requires a targeting key 138 | but one was not provided in the evaluation context. 139 | """ 140 | 141 | def __init__(self, error_message: typing.Optional[str] = None): 142 | """ 143 | Constructor for the TargetingKeyMissingError. The error code for this type of 144 | exception is ErrorCode.TARGETING_KEY_MISSING. 145 | @param error_message: a string message representing why the error has been 146 | raised 147 | """ 148 | super().__init__(ErrorCode.TARGETING_KEY_MISSING, error_message) 149 | 150 | 151 | class InvalidContextError(OpenFeatureError): 152 | """ 153 | This exception should be raised when the evaluation context does not meet provider 154 | requirements. 155 | """ 156 | 157 | def __init__(self, error_message: typing.Optional[str]): 158 | """ 159 | Constructor for the InvalidContextError. The error code for this type of 160 | exception is ErrorCode.INVALID_CONTEXT. 161 | @param error_message: a string message representing why the error has been 162 | raised 163 | """ 164 | super().__init__(ErrorCode.INVALID_CONTEXT, error_message) 165 | 166 | 167 | class ErrorCode(StrEnum): 168 | PROVIDER_NOT_READY = "PROVIDER_NOT_READY" 169 | PROVIDER_FATAL = "PROVIDER_FATAL" 170 | FLAG_NOT_FOUND = "FLAG_NOT_FOUND" 171 | PARSE_ERROR = "PARSE_ERROR" 172 | TYPE_MISMATCH = "TYPE_MISMATCH" 173 | TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING" 174 | INVALID_CONTEXT = "INVALID_CONTEXT" 175 | GENERAL = "GENERAL" 176 | 177 | __exceptions__: Mapping[str, typing.Callable[[str], OpenFeatureError]] = { 178 | PROVIDER_NOT_READY: ProviderNotReadyError, 179 | PROVIDER_FATAL: ProviderFatalError, 180 | FLAG_NOT_FOUND: FlagNotFoundError, 181 | PARSE_ERROR: ParseError, 182 | TYPE_MISMATCH: TypeMismatchError, 183 | TARGETING_KEY_MISSING: TargetingKeyMissingError, 184 | INVALID_CONTEXT: InvalidContextError, 185 | GENERAL: GeneralError, 186 | } 187 | 188 | @classmethod 189 | def to_exception( 190 | cls, error_code: ErrorCode, error_message: str 191 | ) -> OpenFeatureError: 192 | exc = cls.__exceptions__.get(error_code.value, GeneralError) 193 | return exc(error_message) 194 | -------------------------------------------------------------------------------- /openfeature/flag_evaluation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Sequence 5 | from dataclasses import dataclass, field 6 | 7 | from openfeature._backports.strenum import StrEnum 8 | from openfeature.exception import ErrorCode, OpenFeatureError 9 | 10 | if typing.TYPE_CHECKING: # pragma: no cover 11 | # resolves a circular dependency in type annotations 12 | from openfeature.hook import Hook, HookHints 13 | 14 | 15 | __all__ = [ 16 | "FlagEvaluationDetails", 17 | "FlagEvaluationOptions", 18 | "FlagMetadata", 19 | "FlagResolutionDetails", 20 | "FlagType", 21 | "Reason", 22 | ] 23 | 24 | 25 | class FlagType(StrEnum): 26 | BOOLEAN = "BOOLEAN" 27 | STRING = "STRING" 28 | OBJECT = "OBJECT" 29 | FLOAT = "FLOAT" 30 | INTEGER = "INTEGER" 31 | 32 | 33 | class Reason(StrEnum): 34 | CACHED = "CACHED" 35 | DEFAULT = "DEFAULT" 36 | DISABLED = "DISABLED" 37 | ERROR = "ERROR" 38 | SPLIT = "SPLIT" 39 | STATIC = "STATIC" 40 | STALE = "STALE" 41 | TARGETING_MATCH = "TARGETING_MATCH" 42 | UNKNOWN = "UNKNOWN" 43 | 44 | 45 | FlagMetadata = typing.Mapping[str, typing.Union[bool, int, float, str]] 46 | FlagValueType = typing.Union[ 47 | bool, 48 | int, 49 | float, 50 | str, 51 | Sequence["FlagValueType"], 52 | typing.Mapping[str, "FlagValueType"], 53 | ] 54 | 55 | T_co = typing.TypeVar("T_co", covariant=True) 56 | 57 | 58 | @dataclass 59 | class FlagEvaluationDetails(typing.Generic[T_co]): 60 | flag_key: str 61 | value: T_co 62 | variant: typing.Optional[str] = None 63 | flag_metadata: FlagMetadata = field(default_factory=dict) 64 | reason: typing.Optional[typing.Union[str, Reason]] = None 65 | error_code: typing.Optional[ErrorCode] = None 66 | error_message: typing.Optional[str] = None 67 | 68 | def get_exception(self) -> typing.Optional[OpenFeatureError]: 69 | if self.error_code: 70 | return ErrorCode.to_exception(self.error_code, self.error_message or "") 71 | return None 72 | 73 | 74 | @dataclass 75 | class FlagEvaluationOptions: 76 | hooks: list[Hook] = field(default_factory=list) 77 | hook_hints: HookHints = field(default_factory=dict) 78 | 79 | 80 | U_co = typing.TypeVar("U_co", covariant=True) 81 | 82 | 83 | @dataclass 84 | class FlagResolutionDetails(typing.Generic[U_co]): 85 | value: U_co 86 | error_code: typing.Optional[ErrorCode] = None 87 | error_message: typing.Optional[str] = None 88 | reason: typing.Optional[typing.Union[str, Reason]] = None 89 | variant: typing.Optional[str] = None 90 | flag_metadata: FlagMetadata = field(default_factory=dict) 91 | 92 | def raise_for_error(self) -> None: 93 | if self.error_code: 94 | raise ErrorCode.to_exception(self.error_code, self.error_message or "") 95 | return None 96 | 97 | def to_flag_evaluation_details(self, flag_key: str) -> FlagEvaluationDetails[U_co]: 98 | return FlagEvaluationDetails( 99 | flag_key=flag_key, 100 | value=self.value, 101 | variant=self.variant, 102 | flag_metadata=self.flag_metadata, 103 | reason=self.reason, 104 | error_code=self.error_code, 105 | error_message=self.error_message, 106 | ) 107 | -------------------------------------------------------------------------------- /openfeature/hook/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Sequence 5 | from datetime import datetime 6 | from enum import Enum 7 | from typing import TYPE_CHECKING 8 | 9 | from openfeature.evaluation_context import EvaluationContext 10 | from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType 11 | 12 | if TYPE_CHECKING: 13 | from openfeature.client import ClientMetadata 14 | from openfeature.provider.metadata import Metadata 15 | 16 | __all__ = [ 17 | "Hook", 18 | "HookContext", 19 | "HookHints", 20 | "HookType", 21 | "add_hooks", 22 | "clear_hooks", 23 | "get_hooks", 24 | ] 25 | 26 | _hooks: list[Hook] = [] 27 | 28 | 29 | class HookType(Enum): 30 | BEFORE = "before" 31 | AFTER = "after" 32 | FINALLY_AFTER = "finally_after" 33 | ERROR = "error" 34 | 35 | 36 | class HookContext: 37 | def __init__( 38 | self, 39 | flag_key: str, 40 | flag_type: FlagType, 41 | default_value: FlagValueType, 42 | evaluation_context: EvaluationContext, 43 | client_metadata: typing.Optional[ClientMetadata] = None, 44 | provider_metadata: typing.Optional[Metadata] = None, 45 | ): 46 | self.flag_key = flag_key 47 | self.flag_type = flag_type 48 | self.default_value = default_value 49 | self.evaluation_context = evaluation_context 50 | self.client_metadata = client_metadata 51 | self.provider_metadata = provider_metadata 52 | 53 | def __setattr__(self, key: str, value: typing.Any) -> None: 54 | if hasattr(self, key) and key in ( 55 | "flag_key", 56 | "flag_type", 57 | "default_value", 58 | "client_metadata", 59 | "provider_metadata", 60 | ): 61 | raise AttributeError(f"Attribute {key!r} is immutable") 62 | super().__setattr__(key, value) 63 | 64 | 65 | # https://openfeature.dev/specification/sections/hooks/#requirement-421 66 | HookHints = typing.Mapping[ 67 | str, 68 | typing.Union[ 69 | bool, 70 | int, 71 | float, 72 | str, 73 | datetime, 74 | Sequence["HookHints"], 75 | typing.Mapping[str, "HookHints"], 76 | ], 77 | ] 78 | 79 | 80 | class Hook: 81 | def before( 82 | self, hook_context: HookContext, hints: HookHints 83 | ) -> typing.Optional[EvaluationContext]: 84 | """ 85 | Runs before flag is resolved. 86 | 87 | :param hook_context: Information about the particular flag evaluation 88 | :param hints: An immutable mapping of data for users to 89 | communicate to the hooks. 90 | :return: An EvaluationContext. It will be merged with the 91 | EvaluationContext instances from other hooks, the client and API. 92 | """ 93 | return None 94 | 95 | def after( 96 | self, 97 | hook_context: HookContext, 98 | details: FlagEvaluationDetails[FlagValueType], 99 | hints: HookHints, 100 | ) -> None: 101 | """ 102 | Runs after a flag is resolved. 103 | 104 | :param hook_context: Information about the particular flag evaluation 105 | :param details: Information about how the flag was resolved, 106 | including any resolved values. 107 | :param hints: A mapping of data for users to communicate to the hooks. 108 | """ 109 | pass 110 | 111 | def error( 112 | self, hook_context: HookContext, exception: Exception, hints: HookHints 113 | ) -> None: 114 | """ 115 | Run when evaluation encounters an error. Errors thrown will be swallowed. 116 | 117 | :param hook_context: Information about the particular flag evaluation 118 | :param exception: The exception that was thrown 119 | :param hints: A mapping of data for users to communicate to the hooks. 120 | """ 121 | pass 122 | 123 | def finally_after( 124 | self, 125 | hook_context: HookContext, 126 | details: FlagEvaluationDetails[FlagValueType], 127 | hints: HookHints, 128 | ) -> None: 129 | """ 130 | Run after flag evaluation, including any error processing. 131 | This will always run. Errors will be swallowed. 132 | 133 | :param hook_context: Information about the particular flag evaluation 134 | :param hints: A mapping of data for users to communicate to the hooks. 135 | """ 136 | pass 137 | 138 | def supports_flag_value_type(self, flag_type: FlagType) -> bool: 139 | """ 140 | Check to see if the hook supports the particular flag type. 141 | 142 | :param flag_type: particular type of the flag 143 | :return: a boolean containing whether the flag type is supported (True) 144 | or not (False) 145 | """ 146 | return True 147 | 148 | 149 | def add_hooks(hooks: list[Hook]) -> None: 150 | global _hooks 151 | _hooks = _hooks + hooks 152 | 153 | 154 | def clear_hooks() -> None: 155 | global _hooks 156 | _hooks = [] 157 | 158 | 159 | def get_hooks() -> list[Hook]: 160 | return _hooks 161 | -------------------------------------------------------------------------------- /openfeature/hook/_hook_support.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing 3 | from functools import reduce 4 | 5 | from openfeature.evaluation_context import EvaluationContext 6 | from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType 7 | from openfeature.hook import Hook, HookContext, HookHints, HookType 8 | 9 | logger = logging.getLogger("openfeature") 10 | 11 | 12 | def error_hooks( 13 | flag_type: FlagType, 14 | hook_context: HookContext, 15 | exception: Exception, 16 | hooks: list[Hook], 17 | hints: typing.Optional[HookHints] = None, 18 | ) -> None: 19 | kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints} 20 | _execute_hooks( 21 | flag_type=flag_type, hooks=hooks, hook_method=HookType.ERROR, **kwargs 22 | ) 23 | 24 | 25 | def after_all_hooks( 26 | flag_type: FlagType, 27 | hook_context: HookContext, 28 | details: FlagEvaluationDetails[typing.Any], 29 | hooks: list[Hook], 30 | hints: typing.Optional[HookHints] = None, 31 | ) -> None: 32 | kwargs = {"hook_context": hook_context, "details": details, "hints": hints} 33 | _execute_hooks( 34 | flag_type=flag_type, hooks=hooks, hook_method=HookType.FINALLY_AFTER, **kwargs 35 | ) 36 | 37 | 38 | def after_hooks( 39 | flag_type: FlagType, 40 | hook_context: HookContext, 41 | details: FlagEvaluationDetails[typing.Any], 42 | hooks: list[Hook], 43 | hints: typing.Optional[HookHints] = None, 44 | ) -> None: 45 | kwargs = {"hook_context": hook_context, "details": details, "hints": hints} 46 | _execute_hooks_unchecked( 47 | flag_type=flag_type, hooks=hooks, hook_method=HookType.AFTER, **kwargs 48 | ) 49 | 50 | 51 | def before_hooks( 52 | flag_type: FlagType, 53 | hook_context: HookContext, 54 | hooks: list[Hook], 55 | hints: typing.Optional[HookHints] = None, 56 | ) -> EvaluationContext: 57 | kwargs = {"hook_context": hook_context, "hints": hints} 58 | executed_hooks = _execute_hooks_unchecked( 59 | flag_type=flag_type, hooks=hooks, hook_method=HookType.BEFORE, **kwargs 60 | ) 61 | filtered_hooks = [result for result in executed_hooks if result is not None] 62 | 63 | if filtered_hooks: 64 | return reduce(lambda a, b: a.merge(b), filtered_hooks) 65 | 66 | return EvaluationContext() 67 | 68 | 69 | def _execute_hooks( 70 | flag_type: FlagType, 71 | hooks: list[Hook], 72 | hook_method: HookType, 73 | **kwargs: typing.Any, 74 | ) -> list: 75 | """ 76 | Run multiple hooks of any hook type. All of these hooks will be run through an 77 | exception check. 78 | 79 | :param flag_type: particular type of flag 80 | :param hooks: a list of hooks 81 | :param hook_method: the type of hook that is being run 82 | :param kwargs: arguments that need to be provided to the hook method 83 | :return: a list of results from the applied hook methods 84 | """ 85 | return [ 86 | _execute_hook_checked(hook, hook_method, **kwargs) 87 | for hook in hooks 88 | if hook.supports_flag_value_type(flag_type) 89 | ] 90 | 91 | 92 | def _execute_hooks_unchecked( 93 | flag_type: FlagType, 94 | hooks: list[Hook], 95 | hook_method: HookType, 96 | **kwargs: typing.Any, 97 | ) -> list[typing.Optional[EvaluationContext]]: 98 | """ 99 | Execute a single hook without checking whether an exception is thrown. This is 100 | used in the before and after hooks since any exception will be caught in the 101 | client. 102 | 103 | :param flag_type: particular type of flag 104 | :param hooks: a list of hooks 105 | :param hook_method: the type of hook that is being run 106 | :param kwargs: arguments that need to be provided to the hook method 107 | :return: a list of results from the applied hook methods 108 | """ 109 | return [ 110 | getattr(hook, hook_method.value)(**kwargs) 111 | for hook in hooks 112 | if hook.supports_flag_value_type(flag_type) 113 | ] 114 | 115 | 116 | def _execute_hook_checked( 117 | hook: Hook, hook_method: HookType, **kwargs: typing.Any 118 | ) -> typing.Optional[EvaluationContext]: 119 | """ 120 | Try and run a single hook and catch any exception thrown. This is used in the 121 | after all and error hooks since any error thrown at this point needs to be caught. 122 | 123 | :param hook: a list of hooks 124 | :param hook_method: the type of hook that is being run 125 | :param kwargs: arguments that need to be provided to the hook method 126 | :return: the result of the hook method 127 | """ 128 | try: 129 | return typing.cast( 130 | "typing.Optional[EvaluationContext]", 131 | getattr(hook, hook_method.value)(**kwargs), 132 | ) 133 | except Exception: # pragma: no cover 134 | logger.exception(f"Exception when running {hook_method.value} hooks") 135 | return None 136 | -------------------------------------------------------------------------------- /openfeature/immutable_dict/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/openfeature/immutable_dict/__init__.py -------------------------------------------------------------------------------- /openfeature/immutable_dict/mapping_proxy_type.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | 4 | class MappingProxyType(dict): 5 | """ 6 | MappingProxyType is an immutable dictionary type, written to 7 | support Python 3.8 with easy transition to 3.12 upon removal 8 | of older versions. 9 | 10 | See: https://stackoverflow.com/a/72474524 11 | 12 | When upgrading to Python 3.12, you can update all references 13 | from: 14 | `from openfeature.immutable_dict.mapping_proxy_type import MappingProxyType` 15 | 16 | to: 17 | `from types import MappingProxyType` 18 | """ 19 | 20 | def __hash__(self) -> int: # type:ignore[override] 21 | return id(self) 22 | 23 | def _immutable(self, *args: typing.Any, **kws: typing.Any) -> typing.NoReturn: 24 | raise TypeError("immutable instance of dictionary") 25 | 26 | __setitem__ = _immutable 27 | __delitem__ = _immutable 28 | clear = _immutable 29 | update = _immutable 30 | setdefault = _immutable 31 | pop = _immutable 32 | popitem = _immutable 33 | -------------------------------------------------------------------------------- /openfeature/provider/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from abc import abstractmethod 5 | from collections.abc import Sequence 6 | from enum import Enum 7 | 8 | from openfeature.evaluation_context import EvaluationContext 9 | from openfeature.event import ProviderEvent, ProviderEventDetails 10 | from openfeature.flag_evaluation import FlagResolutionDetails 11 | from openfeature.hook import Hook 12 | 13 | from .metadata import Metadata 14 | 15 | if typing.TYPE_CHECKING: 16 | from openfeature.flag_evaluation import FlagValueType 17 | 18 | __all__ = ["AbstractProvider", "FeatureProvider", "Metadata", "ProviderStatus"] 19 | 20 | 21 | class ProviderStatus(Enum): 22 | NOT_READY = "NOT_READY" 23 | READY = "READY" 24 | ERROR = "ERROR" 25 | STALE = "STALE" 26 | FATAL = "FATAL" 27 | 28 | 29 | class FeatureProvider(typing.Protocol): # pragma: no cover 30 | def attach( 31 | self, 32 | on_emit: typing.Callable[ 33 | [FeatureProvider, ProviderEvent, ProviderEventDetails], None 34 | ], 35 | ) -> None: ... 36 | 37 | def detach(self) -> None: ... 38 | 39 | def initialize(self, evaluation_context: EvaluationContext) -> None: ... 40 | 41 | def shutdown(self) -> None: ... 42 | 43 | def get_metadata(self) -> Metadata: ... 44 | 45 | def get_provider_hooks(self) -> list[Hook]: ... 46 | 47 | def resolve_boolean_details( 48 | self, 49 | flag_key: str, 50 | default_value: bool, 51 | evaluation_context: typing.Optional[EvaluationContext] = None, 52 | ) -> FlagResolutionDetails[bool]: ... 53 | 54 | async def resolve_boolean_details_async( 55 | self, 56 | flag_key: str, 57 | default_value: bool, 58 | evaluation_context: typing.Optional[EvaluationContext] = None, 59 | ) -> FlagResolutionDetails[bool]: ... 60 | 61 | def resolve_string_details( 62 | self, 63 | flag_key: str, 64 | default_value: str, 65 | evaluation_context: typing.Optional[EvaluationContext] = None, 66 | ) -> FlagResolutionDetails[str]: ... 67 | 68 | async def resolve_string_details_async( 69 | self, 70 | flag_key: str, 71 | default_value: str, 72 | evaluation_context: typing.Optional[EvaluationContext] = None, 73 | ) -> FlagResolutionDetails[str]: ... 74 | 75 | def resolve_integer_details( 76 | self, 77 | flag_key: str, 78 | default_value: int, 79 | evaluation_context: typing.Optional[EvaluationContext] = None, 80 | ) -> FlagResolutionDetails[int]: ... 81 | 82 | async def resolve_integer_details_async( 83 | self, 84 | flag_key: str, 85 | default_value: int, 86 | evaluation_context: typing.Optional[EvaluationContext] = None, 87 | ) -> FlagResolutionDetails[int]: ... 88 | 89 | def resolve_float_details( 90 | self, 91 | flag_key: str, 92 | default_value: float, 93 | evaluation_context: typing.Optional[EvaluationContext] = None, 94 | ) -> FlagResolutionDetails[float]: ... 95 | 96 | async def resolve_float_details_async( 97 | self, 98 | flag_key: str, 99 | default_value: float, 100 | evaluation_context: typing.Optional[EvaluationContext] = None, 101 | ) -> FlagResolutionDetails[float]: ... 102 | 103 | def resolve_object_details( 104 | self, 105 | flag_key: str, 106 | default_value: typing.Union[ 107 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 108 | ], 109 | evaluation_context: typing.Optional[EvaluationContext] = None, 110 | ) -> FlagResolutionDetails[ 111 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 112 | ]: ... 113 | 114 | async def resolve_object_details_async( 115 | self, 116 | flag_key: str, 117 | default_value: typing.Union[ 118 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 119 | ], 120 | evaluation_context: typing.Optional[EvaluationContext] = None, 121 | ) -> FlagResolutionDetails[ 122 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 123 | ]: ... 124 | 125 | 126 | class AbstractProvider(FeatureProvider): 127 | def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 128 | # this makes sure to invoke the parent of `FeatureProvider` -> `object` 129 | super(FeatureProvider, self).__init__(*args, **kwargs) 130 | 131 | def attach( 132 | self, 133 | on_emit: typing.Callable[ 134 | [FeatureProvider, ProviderEvent, ProviderEventDetails], None 135 | ], 136 | ) -> None: 137 | self._on_emit = on_emit 138 | 139 | def detach(self) -> None: 140 | if hasattr(self, "_on_emit"): 141 | del self._on_emit 142 | 143 | def initialize(self, evaluation_context: EvaluationContext) -> None: 144 | pass 145 | 146 | def shutdown(self) -> None: 147 | pass 148 | 149 | @abstractmethod 150 | def get_metadata(self) -> Metadata: 151 | pass 152 | 153 | def get_provider_hooks(self) -> list[Hook]: 154 | return [] 155 | 156 | @abstractmethod 157 | def resolve_boolean_details( 158 | self, 159 | flag_key: str, 160 | default_value: bool, 161 | evaluation_context: typing.Optional[EvaluationContext] = None, 162 | ) -> FlagResolutionDetails[bool]: 163 | pass 164 | 165 | async def resolve_boolean_details_async( 166 | self, 167 | flag_key: str, 168 | default_value: bool, 169 | evaluation_context: typing.Optional[EvaluationContext] = None, 170 | ) -> FlagResolutionDetails[bool]: 171 | return self.resolve_boolean_details(flag_key, default_value, evaluation_context) 172 | 173 | @abstractmethod 174 | def resolve_string_details( 175 | self, 176 | flag_key: str, 177 | default_value: str, 178 | evaluation_context: typing.Optional[EvaluationContext] = None, 179 | ) -> FlagResolutionDetails[str]: 180 | pass 181 | 182 | async def resolve_string_details_async( 183 | self, 184 | flag_key: str, 185 | default_value: str, 186 | evaluation_context: typing.Optional[EvaluationContext] = None, 187 | ) -> FlagResolutionDetails[str]: 188 | return self.resolve_string_details(flag_key, default_value, evaluation_context) 189 | 190 | @abstractmethod 191 | def resolve_integer_details( 192 | self, 193 | flag_key: str, 194 | default_value: int, 195 | evaluation_context: typing.Optional[EvaluationContext] = None, 196 | ) -> FlagResolutionDetails[int]: 197 | pass 198 | 199 | async def resolve_integer_details_async( 200 | self, 201 | flag_key: str, 202 | default_value: int, 203 | evaluation_context: typing.Optional[EvaluationContext] = None, 204 | ) -> FlagResolutionDetails[int]: 205 | return self.resolve_integer_details(flag_key, default_value, evaluation_context) 206 | 207 | @abstractmethod 208 | def resolve_float_details( 209 | self, 210 | flag_key: str, 211 | default_value: float, 212 | evaluation_context: typing.Optional[EvaluationContext] = None, 213 | ) -> FlagResolutionDetails[float]: 214 | pass 215 | 216 | async def resolve_float_details_async( 217 | self, 218 | flag_key: str, 219 | default_value: float, 220 | evaluation_context: typing.Optional[EvaluationContext] = None, 221 | ) -> FlagResolutionDetails[float]: 222 | return self.resolve_float_details(flag_key, default_value, evaluation_context) 223 | 224 | @abstractmethod 225 | def resolve_object_details( 226 | self, 227 | flag_key: str, 228 | default_value: typing.Union[ 229 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 230 | ], 231 | evaluation_context: typing.Optional[EvaluationContext] = None, 232 | ) -> FlagResolutionDetails[ 233 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 234 | ]: 235 | pass 236 | 237 | async def resolve_object_details_async( 238 | self, 239 | flag_key: str, 240 | default_value: typing.Union[ 241 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 242 | ], 243 | evaluation_context: typing.Optional[EvaluationContext] = None, 244 | ) -> FlagResolutionDetails[ 245 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 246 | ]: 247 | return self.resolve_object_details(flag_key, default_value, evaluation_context) 248 | 249 | def emit_provider_ready(self, details: ProviderEventDetails) -> None: 250 | self.emit(ProviderEvent.PROVIDER_READY, details) 251 | 252 | def emit_provider_configuration_changed( 253 | self, details: ProviderEventDetails 254 | ) -> None: 255 | self.emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details) 256 | 257 | def emit_provider_error(self, details: ProviderEventDetails) -> None: 258 | self.emit(ProviderEvent.PROVIDER_ERROR, details) 259 | 260 | def emit_provider_stale(self, details: ProviderEventDetails) -> None: 261 | self.emit(ProviderEvent.PROVIDER_STALE, details) 262 | 263 | def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None: 264 | if hasattr(self, "_on_emit"): 265 | self._on_emit(self, event, details) 266 | -------------------------------------------------------------------------------- /openfeature/provider/_registry.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from openfeature._event_support import run_handlers_for_provider 4 | from openfeature.evaluation_context import EvaluationContext, get_evaluation_context 5 | from openfeature.event import ( 6 | ProviderEvent, 7 | ProviderEventDetails, 8 | ) 9 | from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError 10 | from openfeature.provider import FeatureProvider, ProviderStatus 11 | from openfeature.provider.no_op_provider import NoOpProvider 12 | 13 | 14 | class ProviderRegistry: 15 | _default_provider: FeatureProvider 16 | _providers: dict[str, FeatureProvider] 17 | _provider_status: dict[FeatureProvider, ProviderStatus] 18 | 19 | def __init__(self) -> None: 20 | self._default_provider = NoOpProvider() 21 | self._providers = {} 22 | self._provider_status = { 23 | self._default_provider: ProviderStatus.READY, 24 | } 25 | 26 | def set_provider(self, domain: str, provider: FeatureProvider) -> None: 27 | if provider is None: 28 | raise GeneralError(error_message="No provider") 29 | providers = self._providers 30 | if domain in providers: 31 | old_provider = providers[domain] 32 | del providers[domain] 33 | if old_provider not in providers.values(): 34 | self._shutdown_provider(old_provider) 35 | if provider not in providers.values(): 36 | self._initialize_provider(provider) 37 | providers[domain] = provider 38 | 39 | def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider: 40 | if domain is None: 41 | return self._default_provider 42 | return self._providers.get(domain, self._default_provider) 43 | 44 | def set_default_provider(self, provider: FeatureProvider) -> None: 45 | if provider is None: 46 | raise GeneralError(error_message="No provider") 47 | if self._default_provider: 48 | self._shutdown_provider(self._default_provider) 49 | self._default_provider = provider 50 | self._initialize_provider(provider) 51 | 52 | def get_default_provider(self) -> FeatureProvider: 53 | return self._default_provider 54 | 55 | def clear_providers(self) -> None: 56 | self.shutdown() 57 | self._providers.clear() 58 | self._default_provider = NoOpProvider() 59 | self._provider_status = { 60 | self._default_provider: ProviderStatus.READY, 61 | } 62 | 63 | def shutdown(self) -> None: 64 | for provider in {self._default_provider, *self._providers.values()}: 65 | self._shutdown_provider(provider) 66 | 67 | def _get_evaluation_context(self) -> EvaluationContext: 68 | return get_evaluation_context() 69 | 70 | def _initialize_provider(self, provider: FeatureProvider) -> None: 71 | provider.attach(self.dispatch_event) 72 | try: 73 | if hasattr(provider, "initialize"): 74 | provider.initialize(self._get_evaluation_context()) 75 | self.dispatch_event( 76 | provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails() 77 | ) 78 | except Exception as err: 79 | error_code = ( 80 | err.error_code 81 | if isinstance(err, OpenFeatureError) 82 | else ErrorCode.GENERAL 83 | ) 84 | self.dispatch_event( 85 | provider, 86 | ProviderEvent.PROVIDER_ERROR, 87 | ProviderEventDetails( 88 | message=f"Provider initialization failed: {err}", 89 | error_code=error_code, 90 | ), 91 | ) 92 | 93 | def _shutdown_provider(self, provider: FeatureProvider) -> None: 94 | try: 95 | if hasattr(provider, "shutdown"): 96 | provider.shutdown() 97 | self._provider_status[provider] = ProviderStatus.NOT_READY 98 | except Exception as err: 99 | self.dispatch_event( 100 | provider, 101 | ProviderEvent.PROVIDER_ERROR, 102 | ProviderEventDetails( 103 | message=f"Provider shutdown failed: {err}", 104 | error_code=ErrorCode.PROVIDER_FATAL, 105 | ), 106 | ) 107 | provider.detach() 108 | 109 | def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus: 110 | return self._provider_status.get(provider, ProviderStatus.NOT_READY) 111 | 112 | def dispatch_event( 113 | self, 114 | provider: FeatureProvider, 115 | event: ProviderEvent, 116 | details: ProviderEventDetails, 117 | ) -> None: 118 | self._update_provider_status(provider, event, details) 119 | run_handlers_for_provider(provider, event, details) 120 | 121 | def _update_provider_status( 122 | self, 123 | provider: FeatureProvider, 124 | event: ProviderEvent, 125 | details: ProviderEventDetails, 126 | ) -> None: 127 | if event == ProviderEvent.PROVIDER_READY: 128 | self._provider_status[provider] = ProviderStatus.READY 129 | elif event == ProviderEvent.PROVIDER_STALE: 130 | self._provider_status[provider] = ProviderStatus.STALE 131 | elif event == ProviderEvent.PROVIDER_ERROR: 132 | status = ( 133 | ProviderStatus.FATAL 134 | if details.error_code == ErrorCode.PROVIDER_FATAL 135 | else ProviderStatus.ERROR 136 | ) 137 | self._provider_status[provider] = status 138 | 139 | 140 | provider_registry = ProviderRegistry() 141 | -------------------------------------------------------------------------------- /openfeature/provider/in_memory_provider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Sequence 5 | from dataclasses import dataclass, field 6 | 7 | from openfeature._backports.strenum import StrEnum 8 | from openfeature.evaluation_context import EvaluationContext 9 | from openfeature.exception import ErrorCode 10 | from openfeature.flag_evaluation import FlagResolutionDetails, Reason 11 | from openfeature.provider import AbstractProvider, Metadata 12 | 13 | if typing.TYPE_CHECKING: 14 | from openfeature.flag_evaluation import FlagMetadata, FlagValueType 15 | from openfeature.hook import Hook 16 | 17 | PASSED_IN_DEFAULT = "Passed in default" 18 | 19 | 20 | @dataclass 21 | class InMemoryMetadata(Metadata): 22 | name: str = "In-Memory Provider" 23 | 24 | 25 | T_co = typing.TypeVar("T_co", covariant=True) 26 | 27 | 28 | @dataclass(frozen=True) 29 | class InMemoryFlag(typing.Generic[T_co]): 30 | class State(StrEnum): 31 | ENABLED = "ENABLED" 32 | DISABLED = "DISABLED" 33 | 34 | default_variant: str 35 | variants: dict[str, T_co] 36 | flag_metadata: FlagMetadata = field(default_factory=dict) 37 | state: State = State.ENABLED 38 | context_evaluator: typing.Optional[ 39 | typing.Callable[ 40 | [InMemoryFlag[T_co], EvaluationContext], FlagResolutionDetails[T_co] 41 | ] 42 | ] = None 43 | 44 | def resolve( 45 | self, evaluation_context: typing.Optional[EvaluationContext] 46 | ) -> FlagResolutionDetails[T_co]: 47 | if self.context_evaluator: 48 | return self.context_evaluator( 49 | self, evaluation_context or EvaluationContext() 50 | ) 51 | 52 | return FlagResolutionDetails( 53 | value=self.variants[self.default_variant], 54 | reason=Reason.STATIC, 55 | variant=self.default_variant, 56 | flag_metadata=self.flag_metadata, 57 | ) 58 | 59 | 60 | FlagStorage = dict[str, InMemoryFlag[typing.Any]] 61 | 62 | V = typing.TypeVar("V") 63 | 64 | 65 | class InMemoryProvider(AbstractProvider): 66 | _flags: FlagStorage 67 | 68 | def __init__(self, flags: FlagStorage) -> None: 69 | self._flags = flags.copy() 70 | 71 | def get_metadata(self) -> Metadata: 72 | return InMemoryMetadata() 73 | 74 | def get_provider_hooks(self) -> list[Hook]: 75 | return [] 76 | 77 | def resolve_boolean_details( 78 | self, 79 | flag_key: str, 80 | default_value: bool, 81 | evaluation_context: typing.Optional[EvaluationContext] = None, 82 | ) -> FlagResolutionDetails[bool]: 83 | return self._resolve(flag_key, default_value, evaluation_context) 84 | 85 | async def resolve_boolean_details_async( 86 | self, 87 | flag_key: str, 88 | default_value: bool, 89 | evaluation_context: typing.Optional[EvaluationContext] = None, 90 | ) -> FlagResolutionDetails[bool]: 91 | return await self._resolve_async(flag_key, default_value, evaluation_context) 92 | 93 | def resolve_string_details( 94 | self, 95 | flag_key: str, 96 | default_value: str, 97 | evaluation_context: typing.Optional[EvaluationContext] = None, 98 | ) -> FlagResolutionDetails[str]: 99 | return self._resolve(flag_key, default_value, evaluation_context) 100 | 101 | async def resolve_string_details_async( 102 | self, 103 | flag_key: str, 104 | default_value: str, 105 | evaluation_context: typing.Optional[EvaluationContext] = None, 106 | ) -> FlagResolutionDetails[str]: 107 | return await self._resolve_async(flag_key, default_value, evaluation_context) 108 | 109 | def resolve_integer_details( 110 | self, 111 | flag_key: str, 112 | default_value: int, 113 | evaluation_context: typing.Optional[EvaluationContext] = None, 114 | ) -> FlagResolutionDetails[int]: 115 | return self._resolve(flag_key, default_value, evaluation_context) 116 | 117 | async def resolve_integer_details_async( 118 | self, 119 | flag_key: str, 120 | default_value: int, 121 | evaluation_context: typing.Optional[EvaluationContext] = None, 122 | ) -> FlagResolutionDetails[int]: 123 | return await self._resolve_async(flag_key, default_value, evaluation_context) 124 | 125 | def resolve_float_details( 126 | self, 127 | flag_key: str, 128 | default_value: float, 129 | evaluation_context: typing.Optional[EvaluationContext] = None, 130 | ) -> FlagResolutionDetails[float]: 131 | return self._resolve(flag_key, default_value, evaluation_context) 132 | 133 | async def resolve_float_details_async( 134 | self, 135 | flag_key: str, 136 | default_value: float, 137 | evaluation_context: typing.Optional[EvaluationContext] = None, 138 | ) -> FlagResolutionDetails[float]: 139 | return await self._resolve_async(flag_key, default_value, evaluation_context) 140 | 141 | def resolve_object_details( 142 | self, 143 | flag_key: str, 144 | default_value: typing.Union[ 145 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 146 | ], 147 | evaluation_context: typing.Optional[EvaluationContext] = None, 148 | ) -> FlagResolutionDetails[ 149 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 150 | ]: 151 | return self._resolve(flag_key, default_value, evaluation_context) 152 | 153 | async def resolve_object_details_async( 154 | self, 155 | flag_key: str, 156 | default_value: typing.Union[ 157 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 158 | ], 159 | evaluation_context: typing.Optional[EvaluationContext] = None, 160 | ) -> FlagResolutionDetails[ 161 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 162 | ]: 163 | return await self._resolve_async(flag_key, default_value, evaluation_context) 164 | 165 | def _resolve( 166 | self, 167 | flag_key: str, 168 | default_value: V, 169 | evaluation_context: typing.Optional[EvaluationContext], 170 | ) -> FlagResolutionDetails[V]: 171 | flag = self._flags.get(flag_key) 172 | if flag is None: 173 | return FlagResolutionDetails( 174 | value=default_value, 175 | reason=Reason.ERROR, 176 | error_code=ErrorCode.FLAG_NOT_FOUND, 177 | error_message=f"Flag '{flag_key}' not found", 178 | ) 179 | return flag.resolve(evaluation_context) 180 | 181 | async def _resolve_async( 182 | self, 183 | flag_key: str, 184 | default_value: V, 185 | evaluation_context: typing.Optional[EvaluationContext], 186 | ) -> FlagResolutionDetails[V]: 187 | return self._resolve(flag_key, default_value, evaluation_context) 188 | -------------------------------------------------------------------------------- /openfeature/provider/metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Metadata: 6 | name: str 7 | -------------------------------------------------------------------------------- /openfeature/provider/no_op_metadata.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from openfeature.provider.metadata import Metadata 4 | 5 | 6 | @dataclass 7 | class NoOpMetadata(Metadata): 8 | name: str = "No-op Provider" 9 | is_default_provider: bool = True 10 | -------------------------------------------------------------------------------- /openfeature/provider/no_op_provider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from collections.abc import Sequence 5 | 6 | from openfeature.flag_evaluation import FlagResolutionDetails, Reason 7 | from openfeature.provider import AbstractProvider 8 | from openfeature.provider.no_op_metadata import NoOpMetadata 9 | 10 | if typing.TYPE_CHECKING: 11 | from openfeature.evaluation_context import EvaluationContext 12 | from openfeature.flag_evaluation import FlagValueType 13 | from openfeature.hook import Hook 14 | from openfeature.provider import Metadata 15 | 16 | PASSED_IN_DEFAULT = "Passed in default" 17 | 18 | 19 | class NoOpProvider(AbstractProvider): 20 | def get_metadata(self) -> Metadata: 21 | return NoOpMetadata() 22 | 23 | def get_provider_hooks(self) -> list[Hook]: 24 | return [] 25 | 26 | def resolve_boolean_details( 27 | self, 28 | flag_key: str, 29 | default_value: bool, 30 | evaluation_context: typing.Optional[EvaluationContext] = None, 31 | ) -> FlagResolutionDetails[bool]: 32 | return FlagResolutionDetails( 33 | value=default_value, 34 | reason=Reason.DEFAULT, 35 | variant=PASSED_IN_DEFAULT, 36 | ) 37 | 38 | def resolve_string_details( 39 | self, 40 | flag_key: str, 41 | default_value: str, 42 | evaluation_context: typing.Optional[EvaluationContext] = None, 43 | ) -> FlagResolutionDetails[str]: 44 | return FlagResolutionDetails( 45 | value=default_value, 46 | reason=Reason.DEFAULT, 47 | variant=PASSED_IN_DEFAULT, 48 | ) 49 | 50 | def resolve_integer_details( 51 | self, 52 | flag_key: str, 53 | default_value: int, 54 | evaluation_context: typing.Optional[EvaluationContext] = None, 55 | ) -> FlagResolutionDetails[int]: 56 | return FlagResolutionDetails( 57 | value=default_value, 58 | reason=Reason.DEFAULT, 59 | variant=PASSED_IN_DEFAULT, 60 | ) 61 | 62 | def resolve_float_details( 63 | self, 64 | flag_key: str, 65 | default_value: float, 66 | evaluation_context: typing.Optional[EvaluationContext] = None, 67 | ) -> FlagResolutionDetails[float]: 68 | return FlagResolutionDetails( 69 | value=default_value, 70 | reason=Reason.DEFAULT, 71 | variant=PASSED_IN_DEFAULT, 72 | ) 73 | 74 | def resolve_object_details( 75 | self, 76 | flag_key: str, 77 | default_value: typing.Union[ 78 | Sequence[FlagValueType], typing.Mapping[str, FlagValueType] 79 | ], 80 | evaluation_context: typing.Optional[EvaluationContext] = None, 81 | ) -> FlagResolutionDetails[ 82 | typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] 83 | ]: 84 | return FlagResolutionDetails( 85 | value=default_value, 86 | reason=Reason.DEFAULT, 87 | variant=PASSED_IN_DEFAULT, 88 | ) 89 | -------------------------------------------------------------------------------- /openfeature/provider/provider.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from openfeature.provider import AbstractProvider 4 | 5 | __all__ = ["AbstractProvider"] 6 | 7 | warnings.warn( 8 | "openfeature.provider.provider is deprecated, use openfeature.provider instead", 9 | DeprecationWarning, 10 | stacklevel=1, 11 | ) 12 | -------------------------------------------------------------------------------- /openfeature/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/openfeature/py.typed -------------------------------------------------------------------------------- /openfeature/telemetry/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from collections.abc import Mapping 3 | from dataclasses import dataclass 4 | 5 | from openfeature.exception import ErrorCode 6 | from openfeature.flag_evaluation import FlagEvaluationDetails, Reason 7 | from openfeature.hook import HookContext 8 | from openfeature.telemetry.attributes import TelemetryAttribute 9 | from openfeature.telemetry.body import TelemetryBodyField 10 | from openfeature.telemetry.metadata import TelemetryFlagMetadata 11 | 12 | __all__ = [ 13 | "EvaluationEvent", 14 | "TelemetryAttribute", 15 | "TelemetryBodyField", 16 | "TelemetryFlagMetadata", 17 | "create_evaluation_event", 18 | ] 19 | 20 | FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation" 21 | 22 | T_co = typing.TypeVar("T_co", covariant=True) 23 | 24 | 25 | @dataclass 26 | class EvaluationEvent(typing.Generic[T_co]): 27 | name: str 28 | attributes: Mapping[TelemetryAttribute, typing.Union[str, T_co]] 29 | body: Mapping[TelemetryBodyField, T_co] 30 | 31 | 32 | def create_evaluation_event( 33 | hook_context: HookContext, details: FlagEvaluationDetails[T_co] 34 | ) -> EvaluationEvent[T_co]: 35 | attributes = { 36 | TelemetryAttribute.KEY: details.flag_key, 37 | TelemetryAttribute.EVALUATION_REASON: ( 38 | details.reason or Reason.UNKNOWN 39 | ).lower(), 40 | } 41 | body = {} 42 | 43 | if variant := details.variant: 44 | attributes[TelemetryAttribute.VARIANT] = variant 45 | else: 46 | body[TelemetryBodyField.VALUE] = details.value 47 | 48 | context_id = details.flag_metadata.get( 49 | TelemetryFlagMetadata.CONTEXT_ID, hook_context.evaluation_context.targeting_key 50 | ) 51 | if context_id: 52 | attributes[TelemetryAttribute.CONTEXT_ID] = typing.cast("str", context_id) 53 | 54 | if set_id := details.flag_metadata.get(TelemetryFlagMetadata.FLAG_SET_ID): 55 | attributes[TelemetryAttribute.SET_ID] = typing.cast("str", set_id) 56 | 57 | if version := details.flag_metadata.get(TelemetryFlagMetadata.VERSION): 58 | attributes[TelemetryAttribute.VERSION] = typing.cast("str", version) 59 | 60 | if metadata := hook_context.provider_metadata: 61 | attributes[TelemetryAttribute.PROVIDER_NAME] = metadata.name 62 | 63 | if details.reason == Reason.ERROR: 64 | attributes[TelemetryAttribute.ERROR_TYPE] = ( 65 | details.error_code or ErrorCode.GENERAL 66 | ).lower() 67 | 68 | if err_msg := details.error_message: 69 | attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] = err_msg 70 | 71 | return EvaluationEvent( 72 | name=FLAG_EVALUATION_EVENT_NAME, 73 | attributes=attributes, 74 | body=body, 75 | ) 76 | -------------------------------------------------------------------------------- /openfeature/telemetry/attributes.py: -------------------------------------------------------------------------------- 1 | from openfeature._backports.strenum import StrEnum 2 | 3 | 4 | class TelemetryAttribute(StrEnum): 5 | """ 6 | The attributes of an OpenTelemetry compliant event for flag evaluation. 7 | 8 | See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ 9 | """ 10 | 11 | CONTEXT_ID = "feature_flag.context.id" 12 | ERROR_TYPE = "error.type" 13 | EVALUATION_ERROR_MESSAGE = "feature_flag.evaluation.error.message" 14 | EVALUATION_REASON = "feature_flag.evaluation.reason" 15 | KEY = "feature_flag.key" 16 | PROVIDER_NAME = "feature_flag.provider_name" 17 | SET_ID = "feature_flag.set.id" 18 | VARIANT = "feature_flag.variant" 19 | VERSION = "feature_flag.version" 20 | -------------------------------------------------------------------------------- /openfeature/telemetry/body.py: -------------------------------------------------------------------------------- 1 | from openfeature._backports.strenum import StrEnum 2 | 3 | 4 | class TelemetryBodyField(StrEnum): 5 | """ 6 | OpenTelemetry event body fields. 7 | 8 | See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ 9 | """ 10 | 11 | VALUE = "value" 12 | -------------------------------------------------------------------------------- /openfeature/telemetry/metadata.py: -------------------------------------------------------------------------------- 1 | from openfeature._backports.strenum import StrEnum 2 | 3 | 4 | class TelemetryFlagMetadata(StrEnum): 5 | """ 6 | Well-known flag metadata attributes for telemetry events. 7 | 8 | See: https://openfeature.dev/specification/appendix-d/#flag-metadata 9 | """ 10 | 11 | CONTEXT_ID = "contextId" 12 | FLAG_SET_ID = "flagSetId" 13 | VERSION = "version" 14 | -------------------------------------------------------------------------------- /openfeature/transaction_context/__init__.py: -------------------------------------------------------------------------------- 1 | from openfeature.evaluation_context import EvaluationContext 2 | from openfeature.transaction_context.context_var_transaction_context_propagator import ( 3 | ContextVarsTransactionContextPropagator, 4 | ) 5 | from openfeature.transaction_context.no_op_transaction_context_propagator import ( 6 | NoOpTransactionContextPropagator, 7 | ) 8 | from openfeature.transaction_context.transaction_context_propagator import ( 9 | TransactionContextPropagator, 10 | ) 11 | 12 | __all__ = [ 13 | "ContextVarsTransactionContextPropagator", 14 | "TransactionContextPropagator", 15 | "get_transaction_context", 16 | "set_transaction_context", 17 | "set_transaction_context_propagator", 18 | ] 19 | 20 | _evaluation_transaction_context_propagator: TransactionContextPropagator = ( 21 | NoOpTransactionContextPropagator() 22 | ) 23 | 24 | 25 | def set_transaction_context_propagator( 26 | transaction_context_propagator: TransactionContextPropagator, 27 | ) -> None: 28 | global _evaluation_transaction_context_propagator 29 | _evaluation_transaction_context_propagator = transaction_context_propagator 30 | 31 | 32 | def get_transaction_context() -> EvaluationContext: 33 | return _evaluation_transaction_context_propagator.get_transaction_context() 34 | 35 | 36 | def set_transaction_context(evaluation_context: EvaluationContext) -> None: 37 | global _evaluation_transaction_context_propagator 38 | _evaluation_transaction_context_propagator.set_transaction_context( 39 | evaluation_context 40 | ) 41 | -------------------------------------------------------------------------------- /openfeature/transaction_context/context_var_transaction_context_propagator.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | from typing import Optional 3 | 4 | from openfeature.evaluation_context import EvaluationContext 5 | from openfeature.transaction_context.transaction_context_propagator import ( 6 | TransactionContextPropagator, 7 | ) 8 | 9 | 10 | class ContextVarsTransactionContextPropagator(TransactionContextPropagator): 11 | _transaction_context_var: ContextVar[Optional[EvaluationContext]] = ContextVar( 12 | "transaction_context", default=None 13 | ) 14 | 15 | def get_transaction_context(self) -> EvaluationContext: 16 | context = self._transaction_context_var.get() 17 | if context is None: 18 | context = EvaluationContext() 19 | self._transaction_context_var.set(context) 20 | 21 | return context 22 | 23 | def set_transaction_context(self, transaction_context: EvaluationContext) -> None: 24 | self._transaction_context_var.set(transaction_context) 25 | -------------------------------------------------------------------------------- /openfeature/transaction_context/no_op_transaction_context_propagator.py: -------------------------------------------------------------------------------- 1 | from openfeature.evaluation_context import EvaluationContext 2 | from openfeature.transaction_context.transaction_context_propagator import ( 3 | TransactionContextPropagator, 4 | ) 5 | 6 | 7 | class NoOpTransactionContextPropagator(TransactionContextPropagator): 8 | def get_transaction_context(self) -> EvaluationContext: 9 | return EvaluationContext() 10 | 11 | def set_transaction_context(self, transaction_context: EvaluationContext) -> None: 12 | pass 13 | -------------------------------------------------------------------------------- /openfeature/transaction_context/transaction_context_propagator.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from openfeature.evaluation_context import EvaluationContext 4 | 5 | 6 | class TransactionContextPropagator(typing.Protocol): 7 | def get_transaction_context(self) -> EvaluationContext: ... 8 | 9 | def set_transaction_context( 10 | self, transaction_context: EvaluationContext 11 | ) -> None: ... 12 | -------------------------------------------------------------------------------- /openfeature/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.8.1" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | [build-system] 3 | requires = ["hatchling"] 4 | build-backend = "hatchling.build" 5 | 6 | [project] 7 | name = "openfeature_sdk" 8 | version = "0.8.1" 9 | description = "Standardizing Feature Flagging for Everyone" 10 | readme = "README.md" 11 | authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] 12 | license = { file = "LICENSE" } 13 | classifiers = [ 14 | "License :: OSI Approved :: Apache Software License", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | ] 18 | keywords = [ 19 | "openfeature", 20 | "feature", 21 | "flags", 22 | "toggles", 23 | ] 24 | dependencies = [] 25 | requires-python = ">=3.9" 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/open-feature/python-sdk" 29 | 30 | [tool.hatch] 31 | 32 | [tool.hatch.envs.default] 33 | dependencies = [ 34 | "behave", 35 | "coverage[toml]>=6.5", 36 | "pytest", 37 | "pytest-asyncio" 38 | ] 39 | 40 | [tool.hatch.envs.default.scripts] 41 | test = "pytest {args:tests}" 42 | test-cov = "coverage run -m pytest {args:tests}" 43 | cov-report = [ 44 | "coverage xml", 45 | ] 46 | cov = [ 47 | "test-cov", 48 | "cov-report", 49 | ] 50 | e2e = [ 51 | "git submodule update --init --recursive", 52 | "cp spec/specification/assets/gherkin/* tests/features/", 53 | "behave tests/features/", 54 | "rm tests/features/*.feature", 55 | ] 56 | 57 | [tool.hatch.build.targets.sdist] 58 | exclude = [ 59 | ".gitignore", 60 | "test-harness", 61 | "venv", 62 | ] 63 | 64 | [tool.hatch.build.targets.wheel] 65 | packages = ["openfeature"] 66 | 67 | [tool.mypy] 68 | files = "openfeature" 69 | 70 | python_version = "3.9" # should be identical to the minimum supported version 71 | namespace_packages = true 72 | explicit_package_bases = true 73 | local_partial_types = true # will become the new default from version 2 74 | pretty = true 75 | strict = true 76 | disallow_any_generics = false 77 | 78 | [tool.pytest.ini_options] 79 | asyncio_default_fixture_loop_scope = "function" 80 | 81 | [tool.coverage.report] 82 | exclude_also = [ 83 | "if TYPE_CHECKING:", 84 | "if typing.TYPE_CHECKING:", 85 | ] 86 | 87 | [tool.ruff] 88 | exclude = [ 89 | ".git", 90 | ".venv", 91 | "__pycache__", 92 | "venv", 93 | ] 94 | target-version = "py39" 95 | 96 | [tool.ruff.lint] 97 | select = [ 98 | "A", 99 | "B", 100 | "C4", 101 | "C90", 102 | "E", 103 | "F", 104 | "FLY", 105 | "FURB", 106 | "I", 107 | "LOG", 108 | "N", 109 | "PERF", 110 | "PGH", 111 | "PLC", 112 | "PLR0913", 113 | "PLR0915", 114 | "RUF", 115 | "S", 116 | "SIM", 117 | "T10", 118 | "T20", 119 | "UP", 120 | "W", 121 | "YTT", 122 | ] 123 | ignore = [ 124 | "E501", # the formatter will handle any too long line 125 | ] 126 | 127 | [tool.ruff.lint.per-file-ignores] 128 | "tests/**/*" = ["PLR0913", "S101"] 129 | 130 | [tool.ruff.lint.pylint] 131 | max-args = 6 132 | max-statements = 30 133 | 134 | [tool.ruff.lint.pyupgrade] 135 | # Preserve types, even if a file imports `from __future__ import annotations`. 136 | keep-runtime-typing = true 137 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "198336b098f167f858675235214cc907ede10182", 3 | "packages": { 4 | ".": { 5 | "release-type": "python", 6 | "monorepo-tags": false, 7 | "include-component-in-tag": false, 8 | "prerelease": false, 9 | "bump-minor-pre-major": true, 10 | "bump-patch-for-minor-pre-major": true, 11 | "extra-files": [ 12 | "README.md" 13 | ] 14 | } 15 | }, 16 | "changelog-sections": [ 17 | { 18 | "type": "fix", 19 | "section": "🐛 Bug Fixes" 20 | }, 21 | { 22 | "type": "feat", 23 | "section": "✨ New Features" 24 | }, 25 | { 26 | "type": "chore", 27 | "section": "🧹 Chore" 28 | }, 29 | { 30 | "type": "docs", 31 | "section": "📚 Documentation" 32 | }, 33 | { 34 | "type": "perf", 35 | "section": "🚀 Performance" 36 | }, 37 | { 38 | "type": "build", 39 | "hidden": true, 40 | "section": "🛠️ Build" 41 | }, 42 | { 43 | "type": "deps", 44 | "section": "📦 Dependencies" 45 | }, 46 | { 47 | "type": "ci", 48 | "hidden": true, 49 | "section": "🚦 CI" 50 | }, 51 | { 52 | "type": "refactor", 53 | "section": "🔄 Refactoring" 54 | }, 55 | { 56 | "type": "revert", 57 | "section": "🔙 Reverts" 58 | }, 59 | { 60 | "type": "style", 61 | "hidden": true, 62 | "section": "🎨 Styling" 63 | }, 64 | { 65 | "type": "test", 66 | "hidden": true, 67 | "section": "🧪 Tests" 68 | } 69 | ], 70 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 71 | } 72 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>open-feature/community-tooling" 5 | ], 6 | "pep621": { 7 | "enabled": true 8 | }, 9 | "pre-commit": { 10 | "enabled": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from openfeature import api 4 | from openfeature.provider.no_op_provider import NoOpProvider 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def clear_providers(): 9 | """ 10 | For tests that use set_provider(), we need to clear the provider to avoid issues 11 | in other tests. 12 | """ 13 | api.clear_providers() 14 | 15 | 16 | @pytest.fixture() 17 | def no_op_provider_client(): 18 | api.set_provider(NoOpProvider()) 19 | return api.get_client() 20 | -------------------------------------------------------------------------------- /tests/evaluation_context/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/evaluation_context/__init__.py -------------------------------------------------------------------------------- /tests/evaluation_context/test_evaluation_context.py: -------------------------------------------------------------------------------- 1 | from openfeature.evaluation_context import EvaluationContext 2 | 3 | 4 | def test_empty_evaluation_context_can_be_merged_with_non_empty_context(): 5 | # Given 6 | empty_context = EvaluationContext() 7 | non_empty_context = EvaluationContext( 8 | targeting_key="targeting_key", attributes={"att1": "value1"} 9 | ) 10 | 11 | # When 12 | merged_context = empty_context.merge(non_empty_context) 13 | 14 | # Then 15 | assert merged_context.attributes == non_empty_context.attributes 16 | assert merged_context.targeting_key == non_empty_context.targeting_key 17 | 18 | 19 | def test_non_empty_context_can_be_merged_with_empty_evaluation_context(): 20 | # Given 21 | empty_context = EvaluationContext() 22 | non_empty_context = EvaluationContext( 23 | targeting_key="targeting_key", attributes={"att1": "value1"} 24 | ) 25 | 26 | # When 27 | merged_context = non_empty_context.merge(empty_context) 28 | 29 | # Then 30 | assert merged_context.attributes == non_empty_context.attributes 31 | assert merged_context.targeting_key == non_empty_context.targeting_key 32 | 33 | 34 | def test_second_targeting_key_overwrites_first(): 35 | # Given 36 | first_context = EvaluationContext( 37 | targeting_key="targeting_key1", attributes={"att1": "value1"} 38 | ) 39 | second_context = EvaluationContext( 40 | targeting_key="targeting_key2", attributes={"att1": "value1"} 41 | ) 42 | 43 | # When 44 | merged_context = first_context.merge(second_context) 45 | 46 | # Then 47 | assert merged_context.targeting_key == second_context.targeting_key 48 | -------------------------------------------------------------------------------- /tests/features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/features/__init__.py -------------------------------------------------------------------------------- /tests/features/data.py: -------------------------------------------------------------------------------- 1 | from openfeature.evaluation_context import EvaluationContext 2 | from openfeature.flag_evaluation import FlagResolutionDetails, Reason 3 | from openfeature.provider.in_memory_provider import InMemoryFlag 4 | 5 | 6 | def context_func(flag: InMemoryFlag, evaluation_context: EvaluationContext): 7 | expects = {"fn": "Sulisław", "ln": "Świętopełk", "age": 29, "customer": False} 8 | 9 | if expects != evaluation_context.attributes: 10 | return FlagResolutionDetails( 11 | value=flag.variants[flag.default_variant], 12 | reason=Reason.DEFAULT, 13 | variant=flag.default_variant, 14 | ) 15 | 16 | return FlagResolutionDetails( 17 | value=flag.variants["internal"], 18 | reason=Reason.TARGETING_MATCH, 19 | variant="internal", 20 | ) 21 | 22 | 23 | IN_MEMORY_FLAGS = { 24 | "boolean-flag": InMemoryFlag( 25 | state=InMemoryFlag.State.ENABLED, 26 | default_variant="on", 27 | variants={"on": True, "off": False}, 28 | context_evaluator=None, 29 | ), 30 | "string-flag": InMemoryFlag( 31 | state=InMemoryFlag.State.ENABLED, 32 | default_variant="greeting", 33 | variants={"greeting": "hi", "parting": "bye"}, 34 | context_evaluator=None, 35 | ), 36 | "integer-flag": InMemoryFlag( 37 | state=InMemoryFlag.State.ENABLED, 38 | default_variant="ten", 39 | variants={"one": 1, "ten": 10}, 40 | context_evaluator=None, 41 | ), 42 | "float-flag": InMemoryFlag( 43 | state=InMemoryFlag.State.ENABLED, 44 | default_variant="half", 45 | variants={"tenth": 0.1, "half": 0.5}, 46 | context_evaluator=None, 47 | ), 48 | "object-flag": InMemoryFlag( 49 | state=InMemoryFlag.State.ENABLED, 50 | default_variant="template", 51 | variants={ 52 | "empty": {}, 53 | "template": { 54 | "showImages": True, 55 | "title": "Check out these pics!", 56 | "imagesPerPage": 100, 57 | }, 58 | }, 59 | context_evaluator=None, 60 | ), 61 | "context-aware": InMemoryFlag( 62 | state=InMemoryFlag.State.ENABLED, 63 | variants={"internal": "INTERNAL", "external": "EXTERNAL"}, 64 | default_variant="external", 65 | context_evaluator=context_func, 66 | ), 67 | "wrong-flag": InMemoryFlag( 68 | state=InMemoryFlag.State.ENABLED, 69 | variants={"one": "uno", "two": "dos"}, 70 | default_variant="one", 71 | ), 72 | "metadata-flag": InMemoryFlag( 73 | state=InMemoryFlag.State.ENABLED, 74 | default_variant="on", 75 | variants={"on": True, "off": False}, 76 | context_evaluator=None, 77 | flag_metadata={ 78 | "string": "1.0.2", 79 | "integer": 2, 80 | "float": 0.1, 81 | "boolean": True, 82 | }, 83 | ), 84 | } 85 | -------------------------------------------------------------------------------- /tests/features/steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/features/steps/__init__.py -------------------------------------------------------------------------------- /tests/features/steps/flag_steps.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from behave import given, when 4 | 5 | 6 | @given('a {flag_type}-flag with key "{flag_key}" and a default value "{default_value}"') 7 | def step_impl_flag(context, flag_type: str, flag_key, default_value): 8 | if default_value.lower() == "true" or default_value.lower() == "false": 9 | default_value = bool(default_value) 10 | try: 11 | default_value = int(default_value) 12 | except ValueError: 13 | with contextlib.suppress(ValueError): 14 | default_value = float(default_value) 15 | context.flag = (flag_type, flag_key, default_value) 16 | 17 | 18 | @when("the flag was evaluated with details") 19 | def step_impl_evaluation(context): 20 | client = context.client 21 | flag_type, key, default_value = context.flag 22 | if flag_type.lower() == "string": 23 | context.evaluation = client.get_string_details(key, default_value) 24 | elif flag_type.lower() == "boolean": 25 | context.evaluation = client.get_boolean_details(key, default_value) 26 | elif flag_type.lower() == "object": 27 | context.evaluation = client.get_object_details(key, default_value) 28 | elif flag_type.lower() == "float": 29 | context.evaluation = client.get_float_details(key, default_value) 30 | elif flag_type.lower() == "integer": 31 | context.evaluation = client.get_integer_details(key, default_value) 32 | -------------------------------------------------------------------------------- /tests/features/steps/hooks_steps.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from behave import given, then 4 | 5 | from openfeature.exception import ErrorCode 6 | from openfeature.flag_evaluation import Reason 7 | from openfeature.hook import Hook 8 | 9 | 10 | @given("a client with added hook") 11 | def step_impl_add_hook(context): 12 | hook = MagicMock(spec=Hook) 13 | hook.before = MagicMock() 14 | hook.after = MagicMock() 15 | hook.error = MagicMock() 16 | hook.finally_after = MagicMock() 17 | context.hook = hook 18 | context.client.add_hooks([hook]) 19 | 20 | 21 | @then('the "{hook_name}" hook should have been executed') 22 | def step_impl_should_called(context, hook_name): 23 | hook = get_hook_from_name(context, hook_name) 24 | assert hook.called 25 | 26 | 27 | @then('the "{hook_names}" hooks should be called with evaluation details') 28 | def step_impl_should_have_eval_details(context, hook_names): 29 | for hook_name in hook_names.split(", "): 30 | hook = get_hook_from_name(context, hook_name) 31 | for row in context.table: 32 | flag_type, key, value = row 33 | 34 | value = convert_value_from_key_and_flag_type(value, key, flag_type) 35 | actual = hook.call_args[1]["details"].__dict__[key] 36 | 37 | assert actual == value 38 | 39 | 40 | def get_hook_from_name(context, hook_name): 41 | if hook_name.lower() == "before": 42 | return context.hook.before 43 | elif hook_name.lower() == "after": 44 | return context.hook.after 45 | elif hook_name.lower() == "error": 46 | return context.hook.error 47 | elif hook_name.lower() == "finally": 48 | return context.hook.finally_after 49 | else: 50 | raise ValueError(str(hook_name) + " is not a valid hook name") 51 | 52 | 53 | def convert_value_from_key_and_flag_type(value, key, flag_type): 54 | if value in ("None", "null"): 55 | return None 56 | if flag_type.lower() == "boolean": 57 | return bool(value) 58 | elif flag_type.lower() == "integer": 59 | return int(value) 60 | elif flag_type.lower() == "float": 61 | return float(value) 62 | elif key == "reason": 63 | return Reason(value) 64 | elif key == "error_code": 65 | return ErrorCode(value) 66 | return value 67 | -------------------------------------------------------------------------------- /tests/features/steps/metadata_steps.py: -------------------------------------------------------------------------------- 1 | from behave import given, then 2 | 3 | from openfeature.api import get_client, set_provider 4 | from openfeature.provider.in_memory_provider import InMemoryProvider 5 | from tests.features.data import IN_MEMORY_FLAGS 6 | 7 | 8 | @given("a stable provider") 9 | def step_impl_stable_provider(context): 10 | set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) 11 | context.client = get_client() 12 | 13 | 14 | @then('the resolved metadata value "{key}" should be "{value}"') 15 | def step_impl_check_metadata(context, key, value): 16 | assert context.evaluation.flag_metadata[key] == value 17 | 18 | 19 | @then("the resolved metadata is empty") 20 | def step_impl_empty_metadata(context): 21 | assert not context.evaluation.flag_metadata 22 | 23 | 24 | @then("the resolved metadata should contain") 25 | def step_impl_metadata_contains(context): 26 | for row in context.table: 27 | key, metadata_type, value = row 28 | 29 | assert context.evaluation.flag_metadata[ 30 | key 31 | ] == convert_value_from_metadata_type(value, metadata_type) 32 | 33 | 34 | def convert_value_from_metadata_type(value, metadata_type): 35 | if value == "None": 36 | return None 37 | if metadata_type.lower() == "boolean": 38 | return bool(value) 39 | elif metadata_type.lower() == "integer": 40 | return int(value) 41 | elif metadata_type.lower() == "float": 42 | return float(value) 43 | return value 44 | -------------------------------------------------------------------------------- /tests/features/steps/steps.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F811 2 | 3 | from time import sleep 4 | 5 | from behave import given, then, when 6 | 7 | from openfeature.api import get_client, set_provider 8 | from openfeature.client import OpenFeatureClient 9 | from openfeature.evaluation_context import EvaluationContext 10 | from openfeature.exception import ErrorCode 11 | from openfeature.flag_evaluation import FlagEvaluationDetails, Reason 12 | from openfeature.provider.in_memory_provider import InMemoryProvider 13 | from tests.features.data import IN_MEMORY_FLAGS 14 | 15 | # Common step definitions 16 | 17 | 18 | @then( 19 | 'the resolved {flag_type} details reason of flag with key "{key}" should be ' 20 | '"{reason}"' 21 | ) 22 | def step_impl_resolved_should_be(context, flag_type, key, expected_reason): 23 | details: FlagEvaluationDetails = None 24 | if flag_type == "boolean": 25 | details = context.boolean_flag_details 26 | assert expected_reason == details.reason.value 27 | 28 | 29 | @given("a provider is registered with cache disabled") 30 | def step_impl_provider_without_cache(context): 31 | set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) 32 | context.client = get_client() 33 | 34 | 35 | @given("a provider is registered") 36 | def step_impl_provider(context): 37 | set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) 38 | context.client = get_client() 39 | 40 | 41 | @when( 42 | 'a {flag_type} flag with key "{key}" is evaluated with details and default value ' 43 | '"{default_value}"' 44 | ) 45 | def step_impl_evaluated_with_details(context, flag_type, key, default_value): 46 | if context.client is None: 47 | context.client = get_client() 48 | if flag_type == "boolean": 49 | context.boolean_flag_details = context.client.get_boolean_details( 50 | key, default_value 51 | ) 52 | elif flag_type == "string": 53 | context.string_flag_details = context.client.get_string_details( 54 | key, default_value 55 | ) 56 | 57 | 58 | @when( 59 | 'a boolean flag with key "{key}" is evaluated with {eval_details} and default ' 60 | 'value "{default_value}"' 61 | ) 62 | def step_impl_bool_evaluated_with_details_and_default( 63 | context, key, eval_details, default_value 64 | ): 65 | client: OpenFeatureClient = context.client 66 | 67 | context.boolean_flag_details = client.get_boolean_details(key, default_value) 68 | 69 | 70 | @when( 71 | 'a {flag_type} flag with key "{key}" is evaluated with default value ' 72 | '"{default_value}"' 73 | ) 74 | def step_impl_evaluated_with_default(context, flag_type, key, default_value): 75 | client: OpenFeatureClient = context.client 76 | 77 | if flag_type == "boolean": 78 | context.boolean_flag_details = client.get_boolean_details(key, default_value) 79 | elif flag_type == "string": 80 | context.string_flag_details = client.get_string_details(key, default_value) 81 | 82 | 83 | @then('the resolved string value should be "{expected_value}"') 84 | def step_impl_resolved_string_should_be(context, expected_value): 85 | assert expected_value == context.string_flag_details.value 86 | 87 | 88 | @then('the resolved boolean value should be "{expected_value}"') 89 | def step_impl_resolved_bool_should_be(context, expected_value): 90 | assert parse_boolean(expected_value) == context.boolean_flag_details.value 91 | 92 | 93 | @when( 94 | 'an integer flag with key "{key}" is evaluated with details and default value ' 95 | "{default_value:d}" 96 | ) 97 | def step_impl_int_evaluated_with_details_and_default(context, key, default_value): 98 | context.flag_key = key 99 | context.default_value = default_value 100 | context.integer_flag_details = context.client.get_integer_details( 101 | key, default_value 102 | ) 103 | 104 | 105 | @when( 106 | 'an integer flag with key "{key}" is evaluated with default value {default_value:d}' 107 | ) 108 | def step_impl_int_evaluated_with_default(context, key, default_value): 109 | context.flag_key = key 110 | context.default_value = default_value 111 | context.integer_flag_details = context.client.get_integer_details( 112 | key, default_value 113 | ) 114 | 115 | 116 | @when('a float flag with key "{key}" is evaluated with default value {default_value:f}') 117 | def step_impl_float_evaluated_with_default(context, key, default_value): 118 | context.flag_key = key 119 | context.default_value = default_value 120 | context.float_flag_details = context.client.get_float_details(key, default_value) 121 | 122 | 123 | @when('an object flag with key "{key}" is evaluated with a null default value') 124 | def step_impl_obj_evaluated_with_default(context, key): 125 | context.flag_key = key 126 | context.default_value = None 127 | context.object_flag_details = context.client.get_object_details(key, None) 128 | 129 | 130 | @then("the resolved integer value should be {expected_value:d}") 131 | def step_impl_resolved_int_should_be(context, expected_value): 132 | assert expected_value == context.integer_flag_details.value 133 | 134 | 135 | @then("the resolved float value should be {expected_value:f}") 136 | def step_impl_resolved_bool_should_be(context, expected_value): 137 | assert expected_value == context.float_flag_details.value 138 | 139 | 140 | # Flag evaluation step definitions 141 | @then( 142 | 'the resolved boolean details value should be "{expected_value}", the variant ' 143 | 'should be "{variant}", and the reason should be "{reason}"' 144 | ) 145 | def step_impl_resolved_bool_should_be_with_reason( 146 | context, expected_value, variant, reason 147 | ): 148 | assert parse_boolean(expected_value) == context.boolean_flag_details.value 149 | assert variant == context.boolean_flag_details.variant 150 | assert reason == context.boolean_flag_details.reason 151 | 152 | 153 | @then( 154 | 'the resolved string details value should be "{expected_value}", the variant ' 155 | 'should be "{variant}", and the reason should be "{reason}"' 156 | ) 157 | def step_impl_resolved_string_should_be_with_reason( 158 | context, expected_value, variant, reason 159 | ): 160 | assert expected_value == context.string_flag_details.value 161 | assert variant == context.string_flag_details.variant 162 | assert reason == context.string_flag_details.reason 163 | 164 | 165 | @then( 166 | 'the resolved object value should be contain fields "{field1}", "{field2}", and ' 167 | '"{field3}", with values "{val1}", "{val2}" and {val3}, respectively' 168 | ) 169 | def step_impl_resolved_obj_should_contain( 170 | context, field1, field2, field3, val1, val2, val3 171 | ): 172 | value = context.object_flag_details.value 173 | assert field1 in value 174 | assert field2 in value 175 | assert field3 in value 176 | assert value[field1] == parse_any(val1) 177 | assert value[field2] == parse_any(val2) 178 | assert value[field3] == parse_any(val3) 179 | 180 | 181 | @then('the resolved flag value is "{flag_value}" when the context is empty') 182 | def step_impl_resolved_is_with_empty_context(context, flag_value): 183 | context.string_flag_details = context.client.get_boolean_details( 184 | context.flag_key, context.default_value 185 | ) 186 | assert flag_value == context.string_flag_details.value 187 | 188 | 189 | @then( 190 | "the reason should indicate an error and the error code should indicate a missing " 191 | 'flag with "{error_code}"' 192 | ) 193 | def step_impl_reason_should_indicate(context, error_code): 194 | assert context.string_flag_details.reason == Reason.ERROR 195 | assert context.string_flag_details.error_code == ErrorCode[error_code] 196 | 197 | 198 | @then("the default {flag_type} value should be returned") 199 | def step_impl_return_default(context, flag_type): 200 | flag_details = getattr(context, f"{flag_type}_flag_details") 201 | assert context.default_value == flag_details.value 202 | 203 | 204 | @when( 205 | 'a float flag with key "{key}" is evaluated with details and default value ' 206 | "{default_value:f}" 207 | ) 208 | def step_impl_float_with_details(context, key, default_value): 209 | context.float_flag_details = context.client.get_float_details(key, default_value) 210 | 211 | 212 | @then( 213 | "the resolved float details value should be {expected_value:f}, the variant should " 214 | 'be "{variant}", and the reason should be "{reason}"' 215 | ) 216 | def step_impl_resolved_float_with_variant(context, expected_value, variant, reason): 217 | assert expected_value == context.float_flag_details.value 218 | assert variant == context.float_flag_details.variant 219 | assert reason == context.float_flag_details.reason 220 | 221 | 222 | @when( 223 | 'an object flag with key "{key}" is evaluated with details and a null default value' 224 | ) 225 | def step_impl_eval_obj(context, key): 226 | context.object_flag_details = context.client.get_object_details(key, None) 227 | 228 | 229 | @then( 230 | 'the resolved object details value should be contain fields "{field1}", "{field2}",' 231 | ' and "{field3}", with values "{val1}", "{val2}" and {val3}, respectively' 232 | ) 233 | def step_impl_eval_obj_with_fields(context, field1, field2, field3, val1, val2, val3): 234 | value = context.object_flag_details.value 235 | assert field1 in value 236 | assert field2 in value 237 | assert field3 in value 238 | assert value[field1] == parse_any(val1) 239 | assert value[field2] == parse_any(val2) 240 | assert value[field3] == parse_any(val3) 241 | 242 | 243 | @then('the variant should be "{variant}", and the reason should be "{reason}"') 244 | def step_impl_variant(context, variant, reason): 245 | assert variant == context.object_flag_details.variant 246 | assert reason == context.object_flag_details.reason 247 | 248 | 249 | @when( 250 | 'context contains keys "{key1}", "{key2}", "{key3}", "{key4}" with values "{val1}",' 251 | ' "{val2}", {val3:d}, "{val4}"' 252 | ) 253 | def step_impl_context(context, key1, key2, key3, key4, val1, val2, val3, val4): 254 | context.evaluation_context = EvaluationContext( 255 | None, 256 | { 257 | key1: val1, 258 | key2: val2, 259 | key3: val3, 260 | key4: parse_boolean(val4), 261 | }, 262 | ) 263 | 264 | 265 | @when('a flag with key "{key}" is evaluated with default value "{default_value}"') 266 | def step_impl_flag_with_key_and_default(context, key, default_value): 267 | context.flag_key = key 268 | context.default_value = default_value 269 | context.string_flag_details = context.client.get_string_details( 270 | key, default_value, context.evaluation_context 271 | ) 272 | 273 | 274 | @then('the resolved string response should be "{expected_value}"') 275 | def step_impl_reason(context, expected_value): 276 | assert expected_value == context.string_flag_details.value 277 | 278 | 279 | @when( 280 | 'a non-existent string flag with key "{flag_key}" is evaluated with details and a ' 281 | 'default value "{default_value}"' 282 | ) 283 | def step_impl_non_existing(context, flag_key, default_value): 284 | context.flag_key = flag_key 285 | context.default_value = default_value 286 | context.string_flag_details = context.client.get_string_details( 287 | flag_key, default_value 288 | ) 289 | 290 | 291 | @when( 292 | 'a string flag with key "{flag_key}" is evaluated as an integer, with details and a' 293 | " default value {default_value:d}" 294 | ) 295 | def step_impl_string_with_details(context, flag_key, default_value): 296 | context.flag_key = flag_key 297 | context.default_value = default_value 298 | context.integer_flag_details = context.client.get_integer_details( 299 | flag_key, default_value 300 | ) 301 | 302 | 303 | @then( 304 | "the reason should indicate an error and the error code should indicate a type " 305 | 'mismatch with "{error_code}"' 306 | ) 307 | def step_impl_type_mismatch(context, error_code): 308 | assert context.integer_flag_details.reason == Reason.ERROR 309 | assert context.integer_flag_details.error_code == ErrorCode[error_code] 310 | 311 | 312 | # Flag caching step definitions 313 | 314 | 315 | @given( 316 | 'the flag\'s configuration with key "{key}" is updated to defaultVariant ' 317 | '"{variant}"' 318 | ) 319 | def step_impl_config_update(context, key, variant): 320 | raise NotImplementedError("Step definition not implemented yet") 321 | 322 | 323 | @given("sleep for {duration} milliseconds") 324 | def step_impl_sleep(context, duration): 325 | sleep(float(duration) * 0.001) 326 | 327 | 328 | @then('the resolved string details reason should be "{reason}"') 329 | def step_impl_reason_should_be(context, reason): 330 | raise NotImplementedError("Step definition not implemented yet") 331 | 332 | 333 | @then( 334 | "the resolved integer details value should be {expected_value:d}, the variant " 335 | 'should be "{variant}", and the reason should be "{reason}"' 336 | ) 337 | def step_impl(context, expected_value, variant, reason): 338 | assert expected_value == context.integer_flag_details.value 339 | assert variant == context.integer_flag_details.variant 340 | assert reason == context.integer_flag_details.reason 341 | 342 | 343 | def parse_boolean(value): 344 | if value == "true": 345 | return True 346 | if value == "false": 347 | return False 348 | raise ValueError(f"Invalid boolean value: {value}") 349 | 350 | 351 | def parse_any(value): 352 | if value == "true": 353 | return True 354 | if value == "false": 355 | return False 356 | if value == "null": 357 | return None 358 | if value.isdigit(): 359 | return int(value) 360 | return value 361 | -------------------------------------------------------------------------------- /tests/hook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/hook/__init__.py -------------------------------------------------------------------------------- /tests/hook/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from openfeature.evaluation_context import EvaluationContext 6 | 7 | 8 | @pytest.fixture() 9 | def mock_hook(): 10 | mock_hook = mock.MagicMock() 11 | mock_hook.supports_flag_value_type.return_value = True 12 | mock_hook.before.return_value = EvaluationContext() 13 | mock_hook.after.return_value = None 14 | mock_hook.error.return_value = None 15 | mock_hook.finally_after.return_value = None 16 | return mock_hook 17 | -------------------------------------------------------------------------------- /tests/hook/test_hook_support.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY, MagicMock 2 | 3 | import pytest 4 | 5 | from openfeature.client import ClientMetadata 6 | from openfeature.evaluation_context import EvaluationContext 7 | from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType 8 | from openfeature.hook import Hook, HookContext 9 | from openfeature.hook._hook_support import ( 10 | after_all_hooks, 11 | after_hooks, 12 | before_hooks, 13 | error_hooks, 14 | ) 15 | from openfeature.immutable_dict.mapping_proxy_type import MappingProxyType 16 | from openfeature.provider.metadata import Metadata 17 | 18 | 19 | def test_hook_context_has_required_and_optional_fields(): 20 | """Requirement 21 | 22 | 4.1.1 - Hook context MUST provide: the "flag key", "flag value type", "evaluation context", and the "default value". 23 | 4.1.2 - The "hook context" SHOULD provide: access to the "client metadata" and the "provider metadata" fields. 24 | """ 25 | 26 | # Given/When 27 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) 28 | 29 | # Then 30 | assert hasattr(hook_context, "flag_key") 31 | assert hasattr(hook_context, "flag_type") 32 | assert hasattr(hook_context, "default_value") 33 | assert hasattr(hook_context, "evaluation_context") 34 | assert hasattr(hook_context, "client_metadata") 35 | assert hasattr(hook_context, "provider_metadata") 36 | 37 | 38 | def test_hook_context_has_immutable_and_mutable_fields(): 39 | """Requirement 40 | 41 | 4.1.3 - The "flag key", "flag type", and "default value" properties MUST be immutable. 42 | 4.1.4.1 - The evaluation context MUST be mutable only within the before hook. 43 | 4.2.2.2 - The client "metadata" field in the "hook context" MUST be immutable. 44 | 4.2.2.3 - The provider "metadata" field in the "hook context" MUST be immutable. 45 | """ 46 | 47 | # Given 48 | hook_context = HookContext( 49 | "flag_key", FlagType.BOOLEAN, True, EvaluationContext(), ClientMetadata("name") 50 | ) 51 | 52 | # When 53 | with pytest.raises(AttributeError): 54 | hook_context.flag_key = "new_key" 55 | with pytest.raises(AttributeError): 56 | hook_context.flag_type = FlagType.STRING 57 | with pytest.raises(AttributeError): 58 | hook_context.default_value = "new_value" 59 | with pytest.raises(AttributeError): 60 | hook_context.client_metadata = ClientMetadata("new_name") 61 | with pytest.raises(AttributeError): 62 | hook_context.provider_metadata = Metadata("name") 63 | 64 | hook_context.evaluation_context = EvaluationContext("targeting_key") 65 | 66 | # Then 67 | assert hook_context.flag_key == "flag_key" 68 | assert hook_context.flag_type is FlagType.BOOLEAN 69 | assert hook_context.default_value is True 70 | assert hook_context.evaluation_context.targeting_key == "targeting_key" 71 | assert hook_context.client_metadata.name == "name" 72 | assert hook_context.provider_metadata is None 73 | 74 | 75 | def test_error_hooks_run_error_method(mock_hook): 76 | # Given 77 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "") 78 | hook_hints = MappingProxyType({}) 79 | # When 80 | error_hooks(FlagType.BOOLEAN, hook_context, Exception, [mock_hook], hook_hints) 81 | # Then 82 | mock_hook.supports_flag_value_type.assert_called_once() 83 | mock_hook.error.assert_called_once() 84 | mock_hook.error.assert_called_with( 85 | hook_context=hook_context, exception=ANY, hints=hook_hints 86 | ) 87 | 88 | 89 | def test_before_hooks_run_before_method(mock_hook): 90 | # Given 91 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "") 92 | hook_hints = MappingProxyType({}) 93 | # When 94 | before_hooks(FlagType.BOOLEAN, hook_context, [mock_hook], hook_hints) 95 | # Then 96 | mock_hook.supports_flag_value_type.assert_called_once() 97 | mock_hook.before.assert_called_once() 98 | mock_hook.before.assert_called_with(hook_context=hook_context, hints=hook_hints) 99 | 100 | 101 | def test_before_hooks_merges_evaluation_contexts(): 102 | # Given 103 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "") 104 | hook_1 = MagicMock(spec=Hook) 105 | hook_1.before.return_value = EvaluationContext("foo", {"key_1": "val_1"}) 106 | hook_2 = MagicMock(spec=Hook) 107 | hook_2.before.return_value = EvaluationContext("bar", {"key_2": "val_2"}) 108 | hook_3 = MagicMock(spec=Hook) 109 | hook_3.before.return_value = None 110 | 111 | # When 112 | context = before_hooks(FlagType.BOOLEAN, hook_context, [hook_1, hook_2, hook_3]) 113 | 114 | # Then 115 | assert context == EvaluationContext("bar", {"key_1": "val_1", "key_2": "val_2"}) 116 | 117 | 118 | def test_after_hooks_run_after_method(mock_hook): 119 | # Given 120 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "") 121 | flag_evaluation_details = FlagEvaluationDetails( 122 | hook_context.flag_key, "val", "unknown" 123 | ) 124 | hook_hints = MappingProxyType({}) 125 | # When 126 | after_hooks( 127 | FlagType.BOOLEAN, hook_context, flag_evaluation_details, [mock_hook], hook_hints 128 | ) 129 | # Then 130 | mock_hook.supports_flag_value_type.assert_called_once() 131 | mock_hook.after.assert_called_once() 132 | mock_hook.after.assert_called_with( 133 | hook_context=hook_context, details=flag_evaluation_details, hints=hook_hints 134 | ) 135 | 136 | 137 | def test_finally_after_hooks_run_finally_after_method(mock_hook): 138 | # Given 139 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "") 140 | flag_evaluation_details = FlagEvaluationDetails( 141 | hook_context.flag_key, "val", "unknown" 142 | ) 143 | hook_hints = MappingProxyType({}) 144 | # When 145 | after_all_hooks( 146 | FlagType.BOOLEAN, hook_context, flag_evaluation_details, [mock_hook], hook_hints 147 | ) 148 | # Then 149 | mock_hook.supports_flag_value_type.assert_called_once() 150 | mock_hook.finally_after.assert_called_once() 151 | mock_hook.finally_after.assert_called_with( 152 | hook_context=hook_context, details=flag_evaluation_details, hints=hook_hints 153 | ) 154 | -------------------------------------------------------------------------------- /tests/provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/provider/__init__.py -------------------------------------------------------------------------------- /tests/provider/test_in_memory_provider.py: -------------------------------------------------------------------------------- 1 | from numbers import Number 2 | 3 | import pytest 4 | 5 | from openfeature.exception import ErrorCode 6 | from openfeature.flag_evaluation import FlagResolutionDetails, Reason 7 | from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider 8 | 9 | 10 | def test_should_return_in_memory_provider_metadata(): 11 | # Given 12 | provider = InMemoryProvider({}) 13 | # When 14 | metadata = provider.get_metadata() 15 | # Then 16 | assert metadata is not None 17 | assert metadata.name == "In-Memory Provider" 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_should_handle_unknown_flags_correctly(): 22 | # Given 23 | provider = InMemoryProvider({}) 24 | # When 25 | flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=True) 26 | flag_async = await provider.resolve_boolean_details_async( 27 | flag_key="Key", default_value=True 28 | ) 29 | # Then 30 | assert flag_sync == flag_async 31 | for flag in [flag_sync, flag_async]: 32 | assert flag is not None 33 | assert flag.value is True 34 | assert flag.reason == Reason.ERROR 35 | assert flag.error_code == ErrorCode.FLAG_NOT_FOUND 36 | assert flag.error_message == "Flag 'Key' not found" 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_calls_context_evaluator_if_present(): 41 | # Given 42 | def context_evaluator(flag: InMemoryFlag, evaluation_context: dict): 43 | return FlagResolutionDetails( 44 | value=False, 45 | reason=Reason.TARGETING_MATCH, 46 | ) 47 | 48 | provider = InMemoryProvider( 49 | { 50 | "Key": InMemoryFlag( 51 | "true", 52 | {"true": True, "false": False}, 53 | context_evaluator=context_evaluator, 54 | ) 55 | } 56 | ) 57 | # When 58 | flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False) 59 | flag_async = await provider.resolve_boolean_details_async( 60 | flag_key="Key", default_value=False 61 | ) 62 | # Then 63 | assert flag_sync == flag_async 64 | for flag in [flag_sync, flag_async]: 65 | assert flag is not None 66 | assert flag.value is False 67 | assert isinstance(flag.value, bool) 68 | assert flag.reason == Reason.TARGETING_MATCH 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_should_resolve_boolean_flag_from_in_memory(): 73 | # Given 74 | provider = InMemoryProvider( 75 | {"Key": InMemoryFlag("true", {"true": True, "false": False})} 76 | ) 77 | # When 78 | flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False) 79 | flag_async = await provider.resolve_boolean_details_async( 80 | flag_key="Key", default_value=False 81 | ) 82 | # Then 83 | assert flag_sync == flag_async 84 | for flag in [flag_sync, flag_async]: 85 | assert flag is not None 86 | assert flag.value is True 87 | assert isinstance(flag.value, bool) 88 | assert flag.variant == "true" 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_should_resolve_integer_flag_from_in_memory(): 93 | # Given 94 | provider = InMemoryProvider( 95 | {"Key": InMemoryFlag("hundred", {"zero": 0, "hundred": 100})} 96 | ) 97 | # When 98 | flag_sync = provider.resolve_integer_details(flag_key="Key", default_value=0) 99 | flag_async = await provider.resolve_integer_details_async( 100 | flag_key="Key", default_value=0 101 | ) 102 | # Then 103 | assert flag_sync == flag_async 104 | for flag in [flag_sync, flag_async]: 105 | assert flag is not None 106 | assert flag.value == 100 107 | assert isinstance(flag.value, Number) 108 | assert flag.variant == "hundred" 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_should_resolve_float_flag_from_in_memory(): 113 | # Given 114 | provider = InMemoryProvider( 115 | {"Key": InMemoryFlag("ten", {"zero": 0.0, "ten": 10.23})} 116 | ) 117 | # When 118 | flag_sync = provider.resolve_float_details(flag_key="Key", default_value=0.0) 119 | flag_async = await provider.resolve_float_details_async( 120 | flag_key="Key", default_value=0.0 121 | ) 122 | # Then 123 | assert flag_sync == flag_async 124 | for flag in [flag_sync, flag_async]: 125 | assert flag is not None 126 | assert flag.value == 10.23 127 | assert isinstance(flag.value, Number) 128 | assert flag.variant == "ten" 129 | 130 | 131 | @pytest.mark.asyncio 132 | async def test_should_resolve_string_flag_from_in_memory(): 133 | # Given 134 | provider = InMemoryProvider( 135 | { 136 | "Key": InMemoryFlag( 137 | "stringVariant", 138 | {"defaultVariant": "Default", "stringVariant": "String"}, 139 | ) 140 | } 141 | ) 142 | # When 143 | flag_sync = provider.resolve_string_details(flag_key="Key", default_value="Default") 144 | flag_async = await provider.resolve_string_details_async( 145 | flag_key="Key", default_value="Default" 146 | ) 147 | # Then 148 | assert flag_sync == flag_async 149 | for flag in [flag_sync, flag_async]: 150 | assert flag is not None 151 | assert flag.value == "String" 152 | assert isinstance(flag.value, str) 153 | assert flag.variant == "stringVariant" 154 | 155 | 156 | @pytest.mark.asyncio 157 | async def test_should_resolve_list_flag_from_in_memory(): 158 | # Given 159 | provider = InMemoryProvider( 160 | {"Key": InMemoryFlag("twoItems", {"empty": [], "twoItems": ["item1", "item2"]})} 161 | ) 162 | # When 163 | flag_sync = provider.resolve_object_details(flag_key="Key", default_value=[]) 164 | flag_async = await provider.resolve_object_details_async( 165 | flag_key="Key", default_value=[] 166 | ) 167 | # Then 168 | assert flag_sync == flag_async 169 | for flag in [flag_sync, flag_async]: 170 | assert flag is not None 171 | assert flag.value == ["item1", "item2"] 172 | assert isinstance(flag.value, list) 173 | assert flag.variant == "twoItems" 174 | 175 | 176 | @pytest.mark.asyncio 177 | async def test_should_resolve_object_flag_from_in_memory(): 178 | # Given 179 | return_value = { 180 | "String": "string", 181 | "Number": 2, 182 | "Boolean": True, 183 | } 184 | provider = InMemoryProvider( 185 | {"Key": InMemoryFlag("obj", {"obj": return_value, "empty": {}})} 186 | ) 187 | # When 188 | flag_sync = provider.resolve_object_details(flag_key="Key", default_value={}) 189 | flag_async = provider.resolve_object_details(flag_key="Key", default_value={}) 190 | # Then 191 | assert flag_sync == flag_async 192 | for flag in [flag_sync, flag_async]: 193 | assert flag is not None 194 | assert flag.value == return_value 195 | assert isinstance(flag.value, dict) 196 | assert flag.variant == "obj" 197 | -------------------------------------------------------------------------------- /tests/provider/test_no_op_provider.py: -------------------------------------------------------------------------------- 1 | from numbers import Number 2 | 3 | from openfeature.provider.no_op_provider import NoOpProvider 4 | 5 | 6 | def test_should_return_no_op_provider_metadata(): 7 | # Given 8 | # When 9 | metadata = NoOpProvider().get_metadata() 10 | # Then 11 | assert metadata is not None 12 | assert metadata.name == "No-op Provider" 13 | assert metadata.is_default_provider 14 | 15 | 16 | def test_should_resolve_boolean_flag_from_no_op(): 17 | # Given 18 | # When 19 | flag = NoOpProvider().resolve_boolean_details(flag_key="Key", default_value=True) 20 | # Then 21 | assert flag is not None 22 | assert flag.value 23 | assert isinstance(flag.value, bool) 24 | 25 | 26 | def test_should_resolve_integer_flag_from_no_op(): 27 | # Given 28 | # When 29 | flag = NoOpProvider().resolve_integer_details(flag_key="Key", default_value=100) 30 | # Then 31 | assert flag is not None 32 | assert flag.value == 100 33 | assert isinstance(flag.value, Number) 34 | 35 | 36 | def test_should_resolve_float_flag_from_no_op(): 37 | # Given 38 | # When 39 | flag = NoOpProvider().resolve_float_details(flag_key="Key", default_value=10.23) 40 | # Then 41 | assert flag is not None 42 | assert flag.value == 10.23 43 | assert isinstance(flag.value, Number) 44 | 45 | 46 | def test_should_resolve_string_flag_from_no_op(): 47 | # Given 48 | # When 49 | flag = NoOpProvider().resolve_string_details(flag_key="Key", default_value="String") 50 | # Then 51 | assert flag is not None 52 | assert flag.value == "String" 53 | assert isinstance(flag.value, str) 54 | 55 | 56 | def test_should_resolve_list_flag_from_no_op(): 57 | # Given 58 | # When 59 | flag = NoOpProvider().resolve_object_details( 60 | flag_key="Key", default_value=["item1", "item2"] 61 | ) 62 | # Then 63 | assert flag is not None 64 | assert flag.value == ["item1", "item2"] 65 | assert isinstance(flag.value, list) 66 | 67 | 68 | def test_should_resolve_object_flag_from_no_op(): 69 | # Given 70 | return_value = { 71 | "String": "string", 72 | "Number": 2, 73 | "Boolean": True, 74 | } 75 | # When 76 | flag = NoOpProvider().resolve_object_details( 77 | flag_key="Key", default_value=return_value 78 | ) 79 | # Then 80 | assert flag is not None 81 | assert flag.value == return_value 82 | assert isinstance(flag.value, dict) 83 | -------------------------------------------------------------------------------- /tests/provider/test_provider_compatibility.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | import pytest 4 | 5 | from openfeature.api import get_client, set_provider 6 | from openfeature.evaluation_context import EvaluationContext 7 | from openfeature.flag_evaluation import FlagResolutionDetails 8 | from openfeature.provider import AbstractProvider, Metadata 9 | 10 | 11 | class SynchronousProvider(AbstractProvider): 12 | def get_metadata(self): 13 | return Metadata(name="SynchronousProvider") 14 | 15 | def get_provider_hooks(self): 16 | return [] 17 | 18 | def resolve_boolean_details( 19 | self, 20 | flag_key: str, 21 | default_value: bool, 22 | evaluation_context: Optional[EvaluationContext] = None, 23 | ) -> FlagResolutionDetails[bool]: 24 | return FlagResolutionDetails(value=True) 25 | 26 | def resolve_string_details( 27 | self, 28 | flag_key: str, 29 | default_value: str, 30 | evaluation_context: Optional[EvaluationContext] = None, 31 | ) -> FlagResolutionDetails[str]: 32 | return FlagResolutionDetails(value="string") 33 | 34 | def resolve_integer_details( 35 | self, 36 | flag_key: str, 37 | default_value: int, 38 | evaluation_context: Optional[EvaluationContext] = None, 39 | ) -> FlagResolutionDetails[int]: 40 | return FlagResolutionDetails(value=1) 41 | 42 | def resolve_float_details( 43 | self, 44 | flag_key: str, 45 | default_value: float, 46 | evaluation_context: Optional[EvaluationContext] = None, 47 | ) -> FlagResolutionDetails[float]: 48 | return FlagResolutionDetails(value=10.0) 49 | 50 | def resolve_object_details( 51 | self, 52 | flag_key: str, 53 | default_value: Union[dict, list], 54 | evaluation_context: Optional[EvaluationContext] = None, 55 | ) -> FlagResolutionDetails[Union[dict, list]]: 56 | return FlagResolutionDetails(value={"key": "value"}) 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "flag_type, default_value, get_method", 61 | ( 62 | (bool, True, "get_boolean_value_async"), 63 | (str, "string", "get_string_value_async"), 64 | (int, 1, "get_integer_value_async"), 65 | (float, 10.0, "get_float_value_async"), 66 | ( 67 | dict, 68 | {"key": "value"}, 69 | "get_object_value_async", 70 | ), 71 | ), 72 | ) 73 | @pytest.mark.asyncio 74 | async def test_sync_provider_can_be_called_async(flag_type, default_value, get_method): 75 | # Given 76 | set_provider(SynchronousProvider(), "SynchronousProvider") 77 | client = get_client("SynchronousProvider") 78 | # When 79 | async_callable = getattr(client, get_method) 80 | flag = await async_callable(flag_key="Key", default_value=default_value) 81 | # Then 82 | assert flag is not None 83 | assert flag == default_value 84 | assert isinstance(flag, flag_type) 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_sync_provider_can_be_extended_async(): 89 | # Given 90 | class ExtendedAsyncProvider(SynchronousProvider): 91 | async def resolve_boolean_details_async( 92 | self, 93 | flag_key: str, 94 | default_value: bool, 95 | evaluation_context: Optional[EvaluationContext] = None, 96 | ) -> FlagResolutionDetails[bool]: 97 | return FlagResolutionDetails(value=False) 98 | 99 | set_provider(ExtendedAsyncProvider(), "ExtendedAsyncProvider") 100 | client = get_client("ExtendedAsyncProvider") 101 | # When 102 | flag = await client.get_boolean_value_async(flag_key="Key", default_value=True) 103 | # Then 104 | assert flag is not None 105 | assert flag is False 106 | 107 | 108 | # We're not allowing providers to only have async methods 109 | def test_sync_methods_enforced_for_async_providers(): 110 | # Given 111 | class AsyncProvider(AbstractProvider): 112 | def get_metadata(self): 113 | return Metadata(name="AsyncProvider") 114 | 115 | async def resolve_boolean_details_async( 116 | self, 117 | flag_key: str, 118 | default_value: bool, 119 | evaluation_context: Optional[EvaluationContext] = None, 120 | ) -> FlagResolutionDetails[bool]: 121 | return FlagResolutionDetails(value=True) 122 | 123 | # When 124 | with pytest.raises(TypeError) as exception: 125 | set_provider(AsyncProvider(), "AsyncProvider") 126 | 127 | # Then 128 | # assert 129 | exception_message = str(exception.value) 130 | assert exception_message.startswith( 131 | "Can't instantiate abstract class AsyncProvider" 132 | ) 133 | assert exception_message.__contains__("resolve_boolean_details") 134 | 135 | 136 | @pytest.mark.asyncio 137 | async def test_async_provider_not_implemented_exception_workaround(): 138 | # Given 139 | class SyncNotImplementedProvider(AbstractProvider): 140 | def get_metadata(self): 141 | return Metadata(name="AsyncProvider") 142 | 143 | async def resolve_boolean_details_async( 144 | self, 145 | flag_key: str, 146 | default_value: bool, 147 | evaluation_context: Optional[EvaluationContext] = None, 148 | ) -> FlagResolutionDetails[bool]: 149 | return FlagResolutionDetails(value=True) 150 | 151 | def resolve_boolean_details( 152 | self, 153 | flag_key: str, 154 | default_value: bool, 155 | evaluation_context: Optional[EvaluationContext] = None, 156 | ) -> FlagResolutionDetails[bool]: 157 | raise NotImplementedError("Use the async method") 158 | 159 | def resolve_string_details( 160 | self, 161 | flag_key: str, 162 | default_value: str, 163 | evaluation_context: Optional[EvaluationContext] = None, 164 | ) -> FlagResolutionDetails[str]: 165 | raise NotImplementedError("Use the async method") 166 | 167 | def resolve_integer_details( 168 | self, 169 | flag_key: str, 170 | default_value: int, 171 | evaluation_context: Optional[EvaluationContext] = None, 172 | ) -> FlagResolutionDetails[int]: 173 | raise NotImplementedError("Use the async method") 174 | 175 | def resolve_float_details( 176 | self, 177 | flag_key: str, 178 | default_value: float, 179 | evaluation_context: Optional[EvaluationContext] = None, 180 | ) -> FlagResolutionDetails[float]: 181 | raise NotImplementedError("Use the async method") 182 | 183 | def resolve_object_details( 184 | self, 185 | flag_key: str, 186 | default_value: Union[dict, list], 187 | evaluation_context: Optional[EvaluationContext] = None, 188 | ) -> FlagResolutionDetails[Union[dict, list]]: 189 | raise NotImplementedError("Use the async method") 190 | 191 | # When 192 | set_provider(SyncNotImplementedProvider(), "SyncNotImplementedProvider") 193 | client = get_client("SyncNotImplementedProvider") 194 | flag = await client.get_boolean_value_async(flag_key="Key", default_value=False) 195 | # Then 196 | assert flag is not None 197 | assert flag is True 198 | -------------------------------------------------------------------------------- /tests/telemetry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/python-sdk/a21413bd5069a9fd32378e197ae7709b34f001a5/tests/telemetry/__init__.py -------------------------------------------------------------------------------- /tests/telemetry/test_evaluation_event.py: -------------------------------------------------------------------------------- 1 | from openfeature.evaluation_context import EvaluationContext 2 | from openfeature.exception import ErrorCode 3 | from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason 4 | from openfeature.hook import HookContext 5 | from openfeature.provider import Metadata 6 | from openfeature.telemetry import ( 7 | TelemetryAttribute, 8 | TelemetryBodyField, 9 | TelemetryFlagMetadata, 10 | create_evaluation_event, 11 | ) 12 | 13 | 14 | def test_create_evaluation_event(): 15 | # given 16 | hook_context = HookContext( 17 | flag_key="flag_key", 18 | flag_type=FlagType.BOOLEAN, 19 | default_value=True, 20 | evaluation_context=EvaluationContext(), 21 | provider_metadata=Metadata(name="test_provider"), 22 | ) 23 | details = FlagEvaluationDetails( 24 | flag_key=hook_context.flag_key, 25 | value=False, 26 | reason=Reason.CACHED, 27 | ) 28 | 29 | # when 30 | event = create_evaluation_event(hook_context=hook_context, details=details) 31 | 32 | # then 33 | assert event.name == "feature_flag.evaluation" 34 | assert event.attributes[TelemetryAttribute.KEY] == "flag_key" 35 | assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "cached" 36 | assert event.attributes[TelemetryAttribute.PROVIDER_NAME] == "test_provider" 37 | assert event.body[TelemetryBodyField.VALUE] is False 38 | 39 | 40 | def test_create_evaluation_event_with_variant(): 41 | # given 42 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) 43 | details = FlagEvaluationDetails( 44 | flag_key=hook_context.flag_key, 45 | value=True, 46 | variant="true", 47 | ) 48 | 49 | # when 50 | event = create_evaluation_event(hook_context=hook_context, details=details) 51 | 52 | # then 53 | assert event.name == "feature_flag.evaluation" 54 | assert event.attributes[TelemetryAttribute.KEY] == "flag_key" 55 | assert event.attributes[TelemetryAttribute.VARIANT] == "true" 56 | assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "unknown" 57 | 58 | 59 | def test_create_evaluation_event_with_metadata(): 60 | # given 61 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) 62 | details = FlagEvaluationDetails( 63 | flag_key=hook_context.flag_key, 64 | value=False, 65 | flag_metadata={ 66 | TelemetryFlagMetadata.CONTEXT_ID: "5157782b-2203-4c80-a857-dbbd5e7761db", 67 | TelemetryFlagMetadata.FLAG_SET_ID: "proj-1", 68 | TelemetryFlagMetadata.VERSION: "v1", 69 | }, 70 | ) 71 | 72 | # when 73 | event = create_evaluation_event(hook_context=hook_context, details=details) 74 | 75 | # then 76 | assert ( 77 | event.attributes[TelemetryAttribute.CONTEXT_ID] 78 | == "5157782b-2203-4c80-a857-dbbd5e7761db" 79 | ) 80 | assert event.attributes[TelemetryAttribute.SET_ID] == "proj-1" 81 | assert event.attributes[TelemetryAttribute.VERSION] == "v1" 82 | 83 | 84 | def test_create_evaluation_event_with_error(): 85 | # given 86 | hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) 87 | details = FlagEvaluationDetails( 88 | flag_key=hook_context.flag_key, 89 | value=False, 90 | reason=Reason.ERROR, 91 | error_code=ErrorCode.FLAG_NOT_FOUND, 92 | error_message="flag error", 93 | ) 94 | 95 | # when 96 | event = create_evaluation_event(hook_context=hook_context, details=details) 97 | 98 | # then 99 | assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "error" 100 | assert event.attributes[TelemetryAttribute.ERROR_TYPE] == "flag_not_found" 101 | assert event.attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] == "flag error" 102 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from openfeature.api import ( 6 | add_handler, 7 | add_hooks, 8 | clear_hooks, 9 | clear_providers, 10 | get_client, 11 | get_evaluation_context, 12 | get_hooks, 13 | get_provider_metadata, 14 | remove_handler, 15 | set_evaluation_context, 16 | set_provider, 17 | shutdown, 18 | ) 19 | from openfeature.evaluation_context import EvaluationContext 20 | from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails 21 | from openfeature.exception import ErrorCode, GeneralError, ProviderFatalError 22 | from openfeature.hook import Hook 23 | from openfeature.provider import FeatureProvider, Metadata, ProviderStatus 24 | from openfeature.provider.no_op_provider import NoOpProvider 25 | 26 | 27 | def test_should_not_raise_exception_with_noop_client(): 28 | # Given 29 | # No provider has been set 30 | # When 31 | client = get_client() 32 | 33 | # Then 34 | assert isinstance(client.provider, NoOpProvider) 35 | 36 | 37 | def test_should_return_open_feature_client_when_configured_correctly(): 38 | # Given 39 | set_provider(NoOpProvider()) 40 | 41 | # When 42 | client = get_client() 43 | 44 | # Then 45 | assert isinstance(client.provider, NoOpProvider) 46 | 47 | 48 | def test_should_try_set_provider_and_fail_if_none_provided(): 49 | # Given 50 | # When 51 | with pytest.raises(GeneralError) as ge: 52 | set_provider(provider=None) 53 | 54 | # Then 55 | assert ge.value.error_message == "No provider" 56 | assert ge.value.error_code == ErrorCode.GENERAL 57 | 58 | 59 | def test_should_invoke_provider_initialize_function_on_newly_registered_provider(): 60 | # Given 61 | evaluation_context = EvaluationContext("targeting_key", {"attr1": "val1"}) 62 | provider = MagicMock(spec=FeatureProvider) 63 | 64 | # When 65 | set_evaluation_context(evaluation_context) 66 | set_provider(provider) 67 | 68 | # Then 69 | provider.initialize.assert_called_with(evaluation_context) 70 | 71 | 72 | def test_should_invoke_provider_shutdown_function_once_provider_is_no_longer_in_use(): 73 | # Given 74 | provider_1 = MagicMock(spec=FeatureProvider) 75 | provider_2 = MagicMock(spec=FeatureProvider) 76 | 77 | # When 78 | set_provider(provider_1) 79 | set_provider(provider_2) 80 | 81 | # Then 82 | assert provider_1.shutdown.called 83 | 84 | 85 | def test_should_retrieve_metadata_for_configured_provider(): 86 | # Given 87 | set_provider(NoOpProvider()) 88 | 89 | # When 90 | metadata = get_provider_metadata() 91 | 92 | # Then 93 | assert isinstance(metadata, Metadata) 94 | assert metadata.name == "No-op Provider" 95 | 96 | 97 | def test_should_raise_an_exception_if_no_evaluation_context_set(): 98 | # Given 99 | with pytest.raises(GeneralError) as ge: 100 | set_evaluation_context(evaluation_context=None) 101 | # Then 102 | assert ge.value 103 | assert ge.value.error_message == "No api level evaluation context" 104 | assert ge.value.error_code == ErrorCode.GENERAL 105 | 106 | 107 | def test_should_successfully_set_evaluation_context_for_api(): 108 | # Given 109 | evaluation_context = EvaluationContext("targeting_key", {"attr1": "val1"}) 110 | 111 | # When 112 | set_evaluation_context(evaluation_context) 113 | global_evaluation_context = get_evaluation_context() 114 | 115 | # Then 116 | assert global_evaluation_context 117 | assert global_evaluation_context.targeting_key == evaluation_context.targeting_key 118 | assert global_evaluation_context.attributes == evaluation_context.attributes 119 | 120 | 121 | def test_should_add_hooks_to_api_hooks(): 122 | # Given 123 | hook_1 = MagicMock(spec=Hook) 124 | hook_2 = MagicMock(spec=Hook) 125 | clear_hooks() 126 | 127 | # When 128 | add_hooks([hook_1]) 129 | add_hooks([hook_2]) 130 | 131 | # Then 132 | assert get_hooks() == [hook_1, hook_2] 133 | 134 | 135 | def test_should_call_provider_shutdown_on_api_shutdown(): 136 | # Given 137 | provider = MagicMock(spec=FeatureProvider) 138 | set_provider(provider) 139 | 140 | # When 141 | shutdown() 142 | 143 | # Then 144 | assert provider.shutdown.called 145 | 146 | 147 | def test_should_provide_a_function_to_bind_provider_through_domain(): 148 | # Given 149 | provider = MagicMock(spec=FeatureProvider) 150 | test_client = get_client("test") 151 | default_client = get_client() 152 | 153 | # When 154 | set_provider(provider, domain="test") 155 | 156 | # Then 157 | assert default_client.provider != provider 158 | assert default_client.domain is None 159 | 160 | assert test_client.provider == provider 161 | assert test_client.domain == "test" 162 | 163 | 164 | def test_should_not_initialize_provider_already_bound_to_another_domain(): 165 | # Given 166 | provider = MagicMock(spec=FeatureProvider) 167 | set_provider(provider, "foo") 168 | 169 | # When 170 | set_provider(provider, "bar") 171 | 172 | # Then 173 | provider.initialize.assert_called_once() 174 | 175 | 176 | def test_should_shutdown_unbound_provider(): 177 | # Given 178 | provider = MagicMock(spec=FeatureProvider) 179 | set_provider(provider, "foo") 180 | 181 | # When 182 | other_provider = MagicMock(spec=FeatureProvider) 183 | set_provider(other_provider, "foo") 184 | 185 | provider.shutdown.assert_called_once() 186 | 187 | 188 | def test_should_not_shutdown_provider_bound_to_another_domain(): 189 | # Given 190 | provider = MagicMock(spec=FeatureProvider) 191 | set_provider(provider, "foo") 192 | set_provider(provider, "bar") 193 | 194 | # When 195 | other_provider = MagicMock(spec=FeatureProvider) 196 | set_provider(other_provider, "foo") 197 | 198 | provider.shutdown.assert_not_called() 199 | 200 | 201 | def test_shutdown_should_shutdown_every_registered_provider_once(): 202 | # Given 203 | provider_1 = MagicMock(spec=FeatureProvider) 204 | provider_2 = MagicMock(spec=FeatureProvider) 205 | set_provider(provider_1) 206 | set_provider(provider_1, "foo") 207 | set_provider(provider_2, "bar") 208 | set_provider(provider_2, "baz") 209 | 210 | # When 211 | shutdown() 212 | 213 | # Then 214 | provider_1.shutdown.assert_called_once() 215 | provider_2.shutdown.assert_called_once() 216 | 217 | 218 | def test_clear_providers_shutdowns_every_provider_and_resets_default_provider(): 219 | # Given 220 | provider_1 = MagicMock(spec=FeatureProvider) 221 | provider_2 = MagicMock(spec=FeatureProvider) 222 | set_provider(provider_1) 223 | set_provider(provider_2, "foo") 224 | set_provider(provider_2, "bar") 225 | 226 | # When 227 | clear_providers() 228 | 229 | # Then 230 | provider_1.shutdown.assert_called_once() 231 | provider_2.shutdown.assert_called_once() 232 | assert isinstance(get_client().provider, NoOpProvider) 233 | 234 | 235 | def test_provider_events(): 236 | # Given 237 | spy = MagicMock() 238 | provider = NoOpProvider() 239 | set_provider(provider) 240 | 241 | add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) 242 | add_handler( 243 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 244 | ) 245 | add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) 246 | add_handler(ProviderEvent.PROVIDER_STALE, spy.provider_stale) 247 | 248 | provider_details = ProviderEventDetails(message="message") 249 | details = EventDetails.from_provider_event_details( 250 | provider.get_metadata().name, provider_details 251 | ) 252 | 253 | # When 254 | provider.emit_provider_configuration_changed(provider_details) 255 | provider.emit_provider_error(provider_details) 256 | provider.emit_provider_stale(provider_details) 257 | 258 | # Then 259 | # NOTE: provider_ready is called immediately after adding the handler 260 | spy.provider_ready.assert_called_once() 261 | spy.provider_configuration_changed.assert_called_once_with(details) 262 | spy.provider_error.assert_called_once_with(details) 263 | spy.provider_stale.assert_called_once_with(details) 264 | 265 | 266 | def test_add_remove_event_handler(): 267 | # Given 268 | provider = NoOpProvider() 269 | set_provider(provider) 270 | 271 | spy = MagicMock() 272 | 273 | add_handler( 274 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 275 | ) 276 | remove_handler( 277 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 278 | ) 279 | 280 | provider_details = ProviderEventDetails(message="message") 281 | 282 | # When 283 | provider.emit_provider_configuration_changed(provider_details) 284 | 285 | # Then 286 | spy.provider_configuration_changed.assert_not_called() 287 | 288 | 289 | # Requirement 5.3.3 290 | def test_handlers_attached_to_provider_already_in_associated_state_should_run_immediately(): 291 | # Given 292 | provider = NoOpProvider() 293 | set_provider(provider) 294 | spy = MagicMock() 295 | 296 | # When 297 | add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) 298 | 299 | # Then 300 | spy.provider_ready.assert_called_once() 301 | 302 | 303 | def test_provider_ready_handlers_run_if_provider_initialize_function_terminates_normally(): 304 | # Given 305 | provider = NoOpProvider() 306 | 307 | spy = MagicMock() 308 | add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) 309 | spy.reset_mock() # reset the mock to avoid counting the immediate call on subscribe 310 | 311 | # When 312 | set_provider(provider) 313 | 314 | # Then 315 | spy.provider_ready.assert_called_once() 316 | 317 | 318 | def test_provider_error_handlers_run_if_provider_initialize_function_terminates_abnormally(): 319 | # Given 320 | provider = MagicMock(spec=FeatureProvider) 321 | provider.initialize.side_effect = ProviderFatalError() 322 | 323 | spy = MagicMock() 324 | add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) 325 | 326 | # When 327 | set_provider(provider) 328 | 329 | # Then 330 | spy.provider_error.assert_called_once() 331 | 332 | 333 | def test_provider_status_is_updated_after_provider_emits_event(): 334 | # Given 335 | provider = NoOpProvider() 336 | set_provider(provider) 337 | client = get_client() 338 | 339 | # When 340 | provider.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.GENERAL)) 341 | # Then 342 | assert client.get_provider_status() == ProviderStatus.ERROR 343 | 344 | # When 345 | provider.emit_provider_error( 346 | ProviderEventDetails(error_code=ErrorCode.PROVIDER_FATAL) 347 | ) 348 | # Then 349 | assert client.get_provider_status() == ProviderStatus.FATAL 350 | 351 | # When 352 | provider.emit_provider_stale(ProviderEventDetails()) 353 | # Then 354 | assert client.get_provider_status() == ProviderStatus.STALE 355 | 356 | # When 357 | provider.emit_provider_ready(ProviderEventDetails()) 358 | # Then 359 | assert client.get_provider_status() == ProviderStatus.READY 360 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import uuid 4 | from concurrent.futures import ThreadPoolExecutor 5 | from unittest.mock import MagicMock 6 | 7 | import pytest 8 | 9 | from openfeature import api 10 | from openfeature.api import add_hooks, clear_hooks, get_client, set_provider 11 | from openfeature.client import OpenFeatureClient, _typecheck_flag_value 12 | from openfeature.evaluation_context import EvaluationContext 13 | from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails 14 | from openfeature.exception import ErrorCode, OpenFeatureError 15 | from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason 16 | from openfeature.hook import Hook 17 | from openfeature.provider import FeatureProvider, ProviderStatus 18 | from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider 19 | from openfeature.provider.no_op_provider import NoOpProvider 20 | from openfeature.transaction_context import ContextVarsTransactionContextPropagator 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "flag_type, default_value, get_method", 25 | ( 26 | (bool, True, "get_boolean_value"), 27 | (bool, True, "get_boolean_value_async"), 28 | (str, "String", "get_string_value"), 29 | (str, "String", "get_string_value_async"), 30 | (int, 100, "get_integer_value"), 31 | (int, 100, "get_integer_value_async"), 32 | (float, 10.23, "get_float_value"), 33 | (float, 10.23, "get_float_value_async"), 34 | ( 35 | dict, 36 | { 37 | "String": "string", 38 | "Number": 2, 39 | "Boolean": True, 40 | }, 41 | "get_object_value", 42 | ), 43 | ( 44 | dict, 45 | { 46 | "String": "string", 47 | "Number": 2, 48 | "Boolean": True, 49 | }, 50 | "get_object_value_async", 51 | ), 52 | ( 53 | list, 54 | ["string1", "string2"], 55 | "get_object_value", 56 | ), 57 | ( 58 | list, 59 | ["string1", "string2"], 60 | "get_object_value_async", 61 | ), 62 | ), 63 | ) 64 | @pytest.mark.asyncio 65 | async def test_should_get_flag_value_based_on_method_type( 66 | flag_type, default_value, get_method, no_op_provider_client 67 | ): 68 | # Given 69 | # When 70 | method = getattr(no_op_provider_client, get_method) 71 | if asyncio.iscoroutinefunction(method): 72 | flag = await method(flag_key="Key", default_value=default_value) 73 | else: 74 | flag = method(flag_key="Key", default_value=default_value) 75 | # Then 76 | assert flag is not None 77 | assert flag == default_value 78 | assert isinstance(flag, flag_type) 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "flag_type, default_value, get_method", 83 | ( 84 | (bool, True, "get_boolean_details"), 85 | (bool, True, "get_boolean_details_async"), 86 | (str, "String", "get_string_details"), 87 | (str, "String", "get_string_details_async"), 88 | (int, 100, "get_integer_details"), 89 | (int, 100, "get_integer_details_async"), 90 | (float, 10.23, "get_float_details"), 91 | (float, 10.23, "get_float_details_async"), 92 | ( 93 | dict, 94 | { 95 | "String": "string", 96 | "Number": 2, 97 | "Boolean": True, 98 | }, 99 | "get_object_details", 100 | ), 101 | ( 102 | dict, 103 | { 104 | "String": "string", 105 | "Number": 2, 106 | "Boolean": True, 107 | }, 108 | "get_object_details_async", 109 | ), 110 | ( 111 | list, 112 | ["string1", "string2"], 113 | "get_object_details", 114 | ), 115 | ( 116 | list, 117 | ["string1", "string2"], 118 | "get_object_details_async", 119 | ), 120 | ), 121 | ) 122 | @pytest.mark.asyncio 123 | async def test_should_get_flag_detail_based_on_method_type( 124 | flag_type, default_value, get_method, no_op_provider_client 125 | ): 126 | # Given 127 | # When 128 | method = getattr(no_op_provider_client, get_method) 129 | if asyncio.iscoroutinefunction(method): 130 | flag = await method(flag_key="Key", default_value=default_value) 131 | else: 132 | flag = method(flag_key="Key", default_value=default_value) 133 | # Then 134 | assert flag is not None 135 | assert flag.value == default_value 136 | assert isinstance(flag.value, flag_type) 137 | 138 | 139 | @pytest.mark.asyncio 140 | async def test_should_raise_exception_when_invalid_flag_type_provided( 141 | no_op_provider_client, 142 | ): 143 | # Given 144 | # When 145 | flag_sync = no_op_provider_client.evaluate_flag_details( 146 | flag_type=None, flag_key="Key", default_value=True 147 | ) 148 | flag_async = await no_op_provider_client.evaluate_flag_details_async( 149 | flag_type=None, flag_key="Key", default_value=True 150 | ) 151 | # Then 152 | for flag in [flag_sync, flag_async]: 153 | assert flag.value 154 | assert flag.error_message == "Unknown flag type" 155 | assert flag.error_code == ErrorCode.GENERAL 156 | assert flag.reason == Reason.ERROR 157 | 158 | 159 | def test_should_pass_flag_metadata_from_resolution_to_evaluation_details(): 160 | # Given 161 | provider = InMemoryProvider( 162 | { 163 | "Key": InMemoryFlag( 164 | "true", 165 | {"true": True, "false": False}, 166 | flag_metadata={"foo": "bar"}, 167 | ) 168 | } 169 | ) 170 | set_provider(provider, "my-client") 171 | 172 | client = OpenFeatureClient("my-client", None) 173 | 174 | # When 175 | details = client.get_boolean_details(flag_key="Key", default_value=False) 176 | 177 | # Then 178 | assert details is not None 179 | assert details.flag_metadata == {"foo": "bar"} 180 | 181 | 182 | def test_should_handle_a_generic_exception_thrown_by_a_provider(no_op_provider_client): 183 | # Given 184 | exception_hook = MagicMock(spec=Hook) 185 | exception_hook.after.side_effect = Exception("Generic exception raised") 186 | no_op_provider_client.add_hooks([exception_hook]) 187 | # When 188 | flag_details = no_op_provider_client.get_boolean_details( 189 | flag_key="Key", default_value=True 190 | ) 191 | # Then 192 | assert flag_details is not None 193 | assert flag_details.value 194 | assert isinstance(flag_details.value, bool) 195 | assert flag_details.reason == Reason.ERROR 196 | assert flag_details.error_message == "Generic exception raised" 197 | 198 | 199 | def test_should_handle_an_open_feature_exception_thrown_by_a_provider( 200 | no_op_provider_client, 201 | ): 202 | # Given 203 | exception_hook = MagicMock(spec=Hook) 204 | exception_hook.after.side_effect = OpenFeatureError( 205 | ErrorCode.GENERAL, "error_message" 206 | ) 207 | no_op_provider_client.add_hooks([exception_hook]) 208 | 209 | # When 210 | flag_details = no_op_provider_client.get_boolean_details( 211 | flag_key="Key", default_value=True 212 | ) 213 | # Then 214 | assert flag_details is not None 215 | assert flag_details.value 216 | assert isinstance(flag_details.value, bool) 217 | assert flag_details.reason == Reason.ERROR 218 | assert flag_details.error_message == "error_message" 219 | 220 | 221 | def test_should_return_client_metadata_with_domain(): 222 | # Given 223 | client = OpenFeatureClient("my-client", None, NoOpProvider()) 224 | # When 225 | metadata = client.get_metadata() 226 | # Then 227 | assert metadata is not None 228 | assert metadata.domain == "my-client" 229 | 230 | 231 | def test_should_call_api_level_hooks(no_op_provider_client): 232 | # Given 233 | clear_hooks() 234 | api_hook = MagicMock(spec=Hook) 235 | add_hooks([api_hook]) 236 | 237 | # When 238 | no_op_provider_client.get_boolean_details(flag_key="Key", default_value=True) 239 | 240 | # Then 241 | api_hook.before.assert_called_once() 242 | api_hook.after.assert_called_once() 243 | 244 | 245 | # Requirement 1.7.5 246 | def test_should_define_a_provider_status_accessor(no_op_provider_client): 247 | # When 248 | status = no_op_provider_client.get_provider_status() 249 | # Then 250 | assert status is not None 251 | assert status == ProviderStatus.READY 252 | 253 | 254 | # Requirement 1.7.6 255 | @pytest.mark.asyncio 256 | async def test_should_shortcircuit_if_provider_is_not_ready( 257 | no_op_provider_client, monkeypatch 258 | ): 259 | # Given 260 | monkeypatch.setattr( 261 | no_op_provider_client, "get_provider_status", lambda: ProviderStatus.NOT_READY 262 | ) 263 | spy_hook = MagicMock(spec=Hook) 264 | no_op_provider_client.add_hooks([spy_hook]) 265 | # When 266 | flag_details_sync = no_op_provider_client.get_boolean_details( 267 | flag_key="Key", default_value=True 268 | ) 269 | spy_hook.error.assert_called_once() 270 | spy_hook.reset_mock() 271 | flag_details_async = await no_op_provider_client.get_boolean_details_async( 272 | flag_key="Key", default_value=True 273 | ) 274 | # Then 275 | for flag_details in [flag_details_sync, flag_details_async]: 276 | assert flag_details is not None 277 | assert flag_details.value 278 | assert flag_details.reason == Reason.ERROR 279 | assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY 280 | spy_hook.error.assert_called_once() 281 | spy_hook.finally_after.assert_called_once() 282 | 283 | 284 | # Requirement 1.7.7 285 | @pytest.mark.asyncio 286 | async def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state( 287 | no_op_provider_client, monkeypatch 288 | ): 289 | # Given 290 | monkeypatch.setattr( 291 | no_op_provider_client, "get_provider_status", lambda: ProviderStatus.FATAL 292 | ) 293 | spy_hook = MagicMock(spec=Hook) 294 | no_op_provider_client.add_hooks([spy_hook]) 295 | # When 296 | flag_details_sync = no_op_provider_client.get_boolean_details( 297 | flag_key="Key", default_value=True 298 | ) 299 | spy_hook.error.assert_called_once() 300 | spy_hook.reset_mock() 301 | flag_details_async = await no_op_provider_client.get_boolean_details_async( 302 | flag_key="Key", default_value=True 303 | ) 304 | # Then 305 | for flag_details in [flag_details_sync, flag_details_async]: 306 | assert flag_details is not None 307 | assert flag_details.value 308 | assert flag_details.reason == Reason.ERROR 309 | assert flag_details.error_code == ErrorCode.PROVIDER_FATAL 310 | spy_hook.error.assert_called_once() 311 | spy_hook.finally_after.assert_called_once() 312 | 313 | 314 | @pytest.mark.asyncio 315 | async def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code(): 316 | # Given 317 | spy_hook = MagicMock(spec=Hook) 318 | provider = MagicMock(spec=FeatureProvider) 319 | provider.get_provider_hooks.return_value = [] 320 | mock_resolution = FlagResolutionDetails( 321 | value=True, 322 | reason=Reason.ERROR, 323 | error_code=ErrorCode.PROVIDER_FATAL, 324 | error_message="This is an error message", 325 | ) 326 | provider.resolve_boolean_details.return_value = mock_resolution 327 | provider.resolve_boolean_details_async.return_value = mock_resolution 328 | set_provider(provider) 329 | client = get_client() 330 | client.add_hooks([spy_hook]) 331 | # When 332 | flag_details_sync = client.get_boolean_details(flag_key="Key", default_value=True) 333 | spy_hook.error.assert_called_once() 334 | spy_hook.reset_mock() 335 | flag_details_async = await client.get_boolean_details_async( 336 | flag_key="Key", default_value=True 337 | ) 338 | # Then 339 | for flag_details in [flag_details_sync, flag_details_async]: 340 | assert flag_details is not None 341 | assert flag_details.value 342 | assert flag_details.reason == Reason.ERROR 343 | assert flag_details.error_code == ErrorCode.PROVIDER_FATAL 344 | spy_hook.error.assert_called_once() 345 | 346 | 347 | @pytest.mark.asyncio 348 | async def test_client_type_mismatch_exceptions(): 349 | # Given 350 | client = get_client() 351 | # When 352 | flag_details_sync = client.get_boolean_details( 353 | flag_key="Key", default_value="type mismatch" 354 | ) 355 | flag_details_async = await client.get_boolean_details_async( 356 | flag_key="Key", default_value="type mismatch" 357 | ) 358 | # Then 359 | for flag_details in [flag_details_sync, flag_details_async]: 360 | assert flag_details is not None 361 | assert flag_details.value 362 | assert flag_details.reason == Reason.ERROR 363 | assert flag_details.error_code == ErrorCode.TYPE_MISMATCH 364 | 365 | 366 | @pytest.mark.asyncio 367 | async def test_typecheck_flag_value_general_error(): 368 | # Given 369 | flag_value = "A" 370 | flag_type = None 371 | # When 372 | err = _typecheck_flag_value(value=flag_value, flag_type=flag_type) 373 | # Then 374 | assert err.error_code == ErrorCode.GENERAL 375 | assert err.error_message == "Unknown flag type" 376 | 377 | 378 | @pytest.mark.asyncio 379 | async def test_typecheck_flag_value_type_mismatch_error(): 380 | # Given 381 | flag_value = "A" 382 | flag_type = FlagType.BOOLEAN 383 | # When 384 | err = _typecheck_flag_value(value=flag_value, flag_type=flag_type) 385 | # Then 386 | assert err.error_code == ErrorCode.TYPE_MISMATCH 387 | assert err.error_message == "Expected type but got " 388 | 389 | 390 | def test_provider_events(): 391 | # Given 392 | provider = NoOpProvider() 393 | set_provider(provider) 394 | 395 | other_provider = NoOpProvider() 396 | set_provider(other_provider, "my-domain") 397 | 398 | provider_details = ProviderEventDetails(message="message") 399 | details = EventDetails.from_provider_event_details( 400 | provider.get_metadata().name, provider_details 401 | ) 402 | 403 | def emit_all_events(provider): 404 | provider.emit_provider_configuration_changed(provider_details) 405 | provider.emit_provider_error(provider_details) 406 | provider.emit_provider_stale(provider_details) 407 | 408 | spy = MagicMock() 409 | 410 | client = get_client() 411 | client.add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) 412 | client.add_handler( 413 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 414 | ) 415 | client.add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) 416 | client.add_handler(ProviderEvent.PROVIDER_STALE, spy.provider_stale) 417 | 418 | # When 419 | emit_all_events(provider) 420 | emit_all_events(other_provider) 421 | 422 | # Then 423 | # NOTE: provider_ready is called immediately after adding the handler 424 | spy.provider_ready.assert_called_once() 425 | spy.provider_configuration_changed.assert_called_once_with(details) 426 | spy.provider_error.assert_called_once_with(details) 427 | spy.provider_stale.assert_called_once_with(details) 428 | 429 | 430 | def test_add_remove_event_handler(): 431 | # Given 432 | provider = NoOpProvider() 433 | set_provider(provider) 434 | 435 | spy = MagicMock() 436 | 437 | client = get_client() 438 | client.add_handler( 439 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 440 | ) 441 | client.remove_handler( 442 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 443 | ) 444 | 445 | provider_details = ProviderEventDetails(message="message") 446 | 447 | # When 448 | provider.emit_provider_configuration_changed(provider_details) 449 | 450 | # Then 451 | spy.provider_configuration_changed.assert_not_called() 452 | 453 | 454 | # Requirement 5.1.2, Requirement 5.1.3 455 | def test_provider_event_late_binding(): 456 | # Given 457 | provider = NoOpProvider() 458 | set_provider(provider, "my-domain") 459 | other_provider = NoOpProvider() 460 | 461 | spy = MagicMock() 462 | 463 | client = get_client("my-domain") 464 | client.add_handler( 465 | ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed 466 | ) 467 | 468 | set_provider(other_provider, "my-domain") 469 | 470 | provider_details = ProviderEventDetails(message="message from provider") 471 | other_provider_details = ProviderEventDetails(message="message from other provider") 472 | 473 | details = EventDetails.from_provider_event_details( 474 | other_provider.get_metadata().name, other_provider_details 475 | ) 476 | 477 | # When 478 | provider.emit_provider_configuration_changed(provider_details) 479 | other_provider.emit_provider_configuration_changed(other_provider_details) 480 | 481 | # Then 482 | spy.provider_configuration_changed.assert_called_once_with(details) 483 | 484 | 485 | def test_client_handlers_thread_safety(): 486 | provider = NoOpProvider() 487 | set_provider(provider) 488 | 489 | def add_handlers_task(): 490 | def handler(*args, **kwargs): 491 | time.sleep(0.005) 492 | 493 | for _ in range(10): 494 | time.sleep(0.01) 495 | client = get_client(str(uuid.uuid4())) 496 | client.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler) 497 | 498 | def emit_events_task(): 499 | for _ in range(10): 500 | time.sleep(0.01) 501 | provider.emit_provider_configuration_changed(ProviderEventDetails()) 502 | 503 | with ThreadPoolExecutor(max_workers=2) as executor: 504 | f1 = executor.submit(add_handlers_task) 505 | f2 = executor.submit(emit_events_task) 506 | f1.result() 507 | f2.result() 508 | 509 | 510 | def test_client_should_merge_contexts(): 511 | api.clear_hooks() 512 | api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) 513 | 514 | provider = NoOpProvider() 515 | provider.resolve_boolean_details = MagicMock(wraps=provider.resolve_boolean_details) 516 | api.set_provider(provider) 517 | 518 | # Global evaluation context 519 | global_context = EvaluationContext( 520 | targeting_key="global", attributes={"global_attr": "global_value"} 521 | ) 522 | api.set_evaluation_context(global_context) 523 | 524 | # Transaction context 525 | transaction_context = EvaluationContext( 526 | targeting_key="transaction", 527 | attributes={"transaction_attr": "transaction_value"}, 528 | ) 529 | api.set_transaction_context(transaction_context) 530 | 531 | # Client-specific context 532 | client_context = EvaluationContext( 533 | targeting_key="client", attributes={"client_attr": "client_value"} 534 | ) 535 | client = OpenFeatureClient(domain=None, version=None, context=client_context) 536 | 537 | # Invocation-specific context 538 | invocation_context = EvaluationContext( 539 | targeting_key="invocation", attributes={"invocation_attr": "invocation_value"} 540 | ) 541 | flag_input = "flag" 542 | flag_default = False 543 | client.get_boolean_details(flag_input, flag_default, invocation_context) 544 | 545 | # Retrieve the call arguments 546 | args, kwargs = provider.resolve_boolean_details.call_args 547 | flag_key, default_value, context = ( 548 | kwargs["flag_key"], 549 | kwargs["default_value"], 550 | kwargs["evaluation_context"], 551 | ) 552 | 553 | assert flag_key == flag_input 554 | assert default_value is flag_default 555 | assert context.targeting_key == "invocation" # Last one in the merge chain 556 | assert context.attributes["global_attr"] == "global_value" 557 | assert context.attributes["transaction_attr"] == "transaction_value" 558 | assert context.attributes["client_attr"] == "client_value" 559 | assert context.attributes["invocation_attr"] == "invocation_value" 560 | -------------------------------------------------------------------------------- /tests/test_flag_evaluation.py: -------------------------------------------------------------------------------- 1 | from openfeature.exception import ErrorCode 2 | from openfeature.flag_evaluation import FlagEvaluationDetails, Reason 3 | 4 | 5 | def test_evaluation_details_reason_should_be_a_string(): 6 | # Given 7 | flag_key = "my-flag" 8 | flag_value = 100 9 | variant = "1-hundred" 10 | flag_metadata = {} 11 | reason = Reason.DEFAULT 12 | error_code = ErrorCode.GENERAL 13 | error_message = "message" 14 | 15 | # When 16 | flag_details = FlagEvaluationDetails( 17 | flag_key, 18 | flag_value, 19 | variant, 20 | flag_metadata, 21 | reason, 22 | error_code, 23 | error_message, 24 | ) 25 | 26 | # Then 27 | assert flag_key == flag_details.flag_key 28 | assert flag_value == flag_details.value 29 | assert variant == flag_details.variant 30 | assert error_code == flag_details.error_code 31 | assert error_message == flag_details.error_message 32 | assert reason == flag_details.reason 33 | 34 | 35 | def test_evaluation_details_reason_should_be_a_string_when_set(): 36 | # Given 37 | flag_key = "my-flag" 38 | flag_value = 100 39 | variant = "1-hundred" 40 | reason = Reason.DEFAULT 41 | error_code = ErrorCode.GENERAL 42 | error_message = "message" 43 | 44 | # When 45 | flag_details = FlagEvaluationDetails( 46 | flag_key, 47 | flag_value, 48 | variant, 49 | reason, 50 | error_code, 51 | error_message, 52 | ) 53 | flag_details.reason = Reason.STATIC 54 | 55 | # Then 56 | assert Reason.STATIC == flag_details.reason # noqa: SIM300 57 | -------------------------------------------------------------------------------- /tests/test_transaction_context.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from openfeature.api import ( 8 | get_transaction_context, 9 | set_transaction_context, 10 | set_transaction_context_propagator, 11 | ) 12 | from openfeature.evaluation_context import EvaluationContext 13 | from openfeature.transaction_context import ( 14 | ContextVarsTransactionContextPropagator, 15 | TransactionContextPropagator, 16 | ) 17 | from openfeature.transaction_context.no_op_transaction_context_propagator import ( 18 | NoOpTransactionContextPropagator, 19 | ) 20 | 21 | 22 | # Test cases 23 | def test_should_return_default_evaluation_context_with_noop_propagator(): 24 | # Given 25 | set_transaction_context_propagator(NoOpTransactionContextPropagator()) 26 | 27 | # When 28 | context = get_transaction_context() 29 | 30 | # Then 31 | assert isinstance(context, EvaluationContext) 32 | assert context.attributes == {} 33 | 34 | 35 | def test_should_set_and_get_custom_transaction_context(): 36 | # Given 37 | set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) 38 | evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"}) 39 | 40 | # When 41 | set_transaction_context(evaluation_context) 42 | 43 | # Then 44 | context = get_transaction_context() 45 | assert context.targeting_key == "custom_key" 46 | assert context.attributes == {"attr1": "val1"} 47 | 48 | 49 | def test_should_override_propagator_and_reset_context(): 50 | # Given 51 | custom_propagator = MagicMock(spec=TransactionContextPropagator) 52 | default_context = EvaluationContext() 53 | 54 | set_transaction_context_propagator(custom_propagator) 55 | 56 | # When 57 | set_transaction_context_propagator(NoOpTransactionContextPropagator()) 58 | 59 | # Then 60 | assert get_transaction_context() == default_context 61 | 62 | 63 | def test_should_call_set_transaction_context_on_propagator(): 64 | # Given 65 | custom_propagator = MagicMock(spec=TransactionContextPropagator) 66 | evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"}) 67 | set_transaction_context_propagator(custom_propagator) 68 | 69 | # When 70 | set_transaction_context(evaluation_context) 71 | 72 | # Then 73 | custom_propagator.set_transaction_context.assert_called_with(evaluation_context) 74 | 75 | 76 | def test_should_return_default_context_with_noop_propagator_set(): 77 | # Given 78 | noop_propagator = NoOpTransactionContextPropagator() 79 | 80 | set_transaction_context_propagator(noop_propagator) 81 | 82 | # When 83 | context = get_transaction_context() 84 | 85 | # Then 86 | assert context == EvaluationContext() 87 | 88 | 89 | def test_should_propagate_event_when_context_set(): 90 | # Given 91 | custom_propagator = ContextVarsTransactionContextPropagator() 92 | set_transaction_context_propagator(custom_propagator) 93 | evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"}) 94 | 95 | # When 96 | set_transaction_context(evaluation_context) 97 | 98 | # Then 99 | assert ( 100 | custom_propagator._transaction_context_var.get().targeting_key == "custom_key" 101 | ) 102 | assert custom_propagator._transaction_context_var.get().attributes == { 103 | "attr1": "val1" 104 | } 105 | 106 | 107 | def test_context_vars_transaction_context_propagator_multiple_threads(): 108 | # Given 109 | context_var_propagator = ContextVarsTransactionContextPropagator() 110 | set_transaction_context_propagator(context_var_propagator) 111 | 112 | number_of_threads = 3 113 | barrier = threading.Barrier(number_of_threads) 114 | 115 | def thread_func(context_value, result_list, index): 116 | context = EvaluationContext( 117 | f"context_{context_value}", {"thread": context_value} 118 | ) 119 | set_transaction_context(context) 120 | barrier.wait() 121 | result_list[index] = get_transaction_context() 122 | 123 | results = [None] * number_of_threads 124 | threads = [] 125 | 126 | # When 127 | for i in range(3): 128 | thread = threading.Thread(target=thread_func, args=(i, results, i)) 129 | threads.append(thread) 130 | thread.start() 131 | 132 | for thread in threads: 133 | thread.join() 134 | 135 | # Then 136 | for i in range(3): 137 | assert results[i].targeting_key == f"context_{i}" 138 | assert results[i].attributes == {"thread": i} 139 | 140 | 141 | @pytest.mark.asyncio 142 | async def test_context_vars_transaction_context_propagator_asyncio(): 143 | # Given 144 | context_var_propagator = ContextVarsTransactionContextPropagator() 145 | set_transaction_context_propagator(context_var_propagator) 146 | 147 | number_of_tasks = 3 148 | event = asyncio.Event() 149 | ready_count = 0 150 | 151 | async def async_func(context_value, results, index): 152 | nonlocal ready_count 153 | context = EvaluationContext( 154 | f"context_{context_value}", {"async": context_value} 155 | ) 156 | set_transaction_context(context) 157 | 158 | ready_count += 1 # Increment the ready count 159 | if ready_count == number_of_tasks: 160 | event.set() # Set the event when all tasks are ready 161 | 162 | await event.wait() # Wait for the event to be set 163 | results[index] = get_transaction_context() 164 | 165 | # Placeholder for results 166 | results = [None] * number_of_tasks 167 | 168 | # When 169 | tasks = [async_func(i, results, i) for i in range(number_of_tasks)] 170 | await asyncio.gather(*tasks) 171 | 172 | # Then 173 | for i in range(number_of_tasks): 174 | assert results[i].targeting_key == f"context_{i}" 175 | assert results[i].attributes == {"async": i} 176 | --------------------------------------------------------------------------------