├── .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 |
7 |
8 |
9 |
10 | OpenFeature Python SDK
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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 |
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 |
--------------------------------------------------------------------------------