Compare commits
297 Commits
Author | SHA1 | Date |
---|---|---|
|
32fdec1781 | |
|
9d0cbe8d4a | |
|
1802022994 | |
|
a3698902b5 | |
|
a5b3aa9c52 | |
|
5652c0c457 | |
|
00cab65315 | |
|
90a193d22c | |
|
288bd6bb34 | |
|
a04e52c022 | |
|
21ef53a156 | |
|
6da7890ac6 | |
|
fb47cbb2a5 | |
|
7783a8b6c7 | |
|
d21d9db90a | |
|
ac95c7a5b7 | |
|
4628c24f5c | |
|
a21413bd50 | |
|
347517a7cc | |
|
f95b27a25a | |
|
87e448593d | |
|
8dfa88cf8a | |
|
abb3137779 | |
|
c722cf0239 | |
|
7bb0f5e499 | |
|
1dd8b29493 | |
|
42fed6b200 | |
|
8aedfe81ef | |
|
1f169551e3 | |
|
f4f9a12081 | |
|
ad69f2c55f | |
|
e0de4b2faa | |
|
7fe752d8fd | |
|
d54d239a2d | |
|
798ac8ded0 | |
|
95be943d33 | |
|
4006df768c | |
|
3636a0d75f | |
|
e61b69bb50 | |
|
5a2825b00d | |
|
8acc883288 | |
|
7a30ef914b | |
|
1ae9fc2361 | |
|
0ebec538db | |
|
2be2c06569 | |
|
a1359112e9 | |
|
490cd06853 | |
|
9ced6bf2d1 | |
|
4eeab3b691 | |
|
95e87c71fc | |
|
c07d3d6467 | |
|
d69b7594a9 | |
|
d1eb3a08a8 | |
|
d15388b542 | |
|
5fede4d4f0 | |
|
0396592586 | |
|
9057c6b3df | |
|
547781fbd8 | |
|
40cbd82dda | |
|
9ce51ebff5 | |
|
0c1a388ca1 | |
|
a666227f55 | |
|
fe99f08e94 | |
|
2d1ba85c93 | |
|
613388ddde | |
|
a5cb27b678 | |
|
088409ea5c | |
|
11987280ba | |
|
95b33b39e6 | |
|
31afa6490f | |
|
f29c4506a6 | |
|
f907855966 | |
|
5ae8571ccd | |
|
2951eb2982 | |
|
bcd1a3807e | |
|
d4f53b4de2 | |
|
37296dc0b5 | |
|
ba0213e701 | |
|
75b41dd020 | |
|
86e7c07112 | |
|
154d8345e7 | |
|
38d13fa454 | |
|
e705af47b1 | |
|
ff521630a1 | |
|
49edce2269 | |
|
fe0fea1f73 | |
|
c2d1402641 | |
|
99905d57f8 | |
|
384c119d2e | |
|
f72670689d | |
|
9e9bb5c626 | |
|
8f2cabaa32 | |
|
9c2ed71c6e | |
|
e99e481524 | |
|
8f9cc7ca96 | |
|
6af37b1c2b | |
|
192f7c40bd | |
|
b69e81a636 | |
|
995e8b0394 | |
|
f559d1b27a | |
|
9304292ea9 | |
|
cbace6a24c | |
|
bc6e333215 | |
|
7f9d422497 | |
|
2c1840c87d | |
|
26bc964227 | |
|
89d6997b1f | |
|
3296d3b229 | |
|
1c564804af | |
|
01ec388d2d | |
|
a6907d610e | |
|
0459330cb9 | |
|
2b6e210bc9 | |
|
043385a836 | |
|
e6ada0f413 | |
|
cd737a9a6a | |
|
70acd1dd42 | |
|
398dcb04a0 | |
|
9b97130908 | |
|
f024a6f340 | |
|
24970b5d09 | |
|
a652004fde | |
|
9759bfaa21 | |
|
3fb7cbc3dd | |
|
e72e329a2a | |
|
3d77f24107 | |
|
6cd570f64c | |
|
1eb6f4c655 | |
|
2d8fadf40a | |
|
05dc3be00f | |
|
c3d70ec457 | |
|
f5aaac0525 | |
|
091656b653 | |
|
157e1baddb | |
|
00f026d4f1 | |
|
7a6b3c78cc | |
|
977cd6d4b7 | |
|
d2c6b401cd | |
|
45d2a6daca | |
|
dcdb56c43f | |
|
65b0f1a374 | |
|
a334f20251 | |
|
4959144a50 | |
|
ca4d589456 | |
|
e57ff78306 | |
|
6e316a216a | |
|
f38ff919bd | |
|
8e6a530040 | |
|
90d417dbd3 | |
|
e538f86e76 | |
|
a6914808b8 | |
|
26b7114f4b | |
|
261aa4168e | |
|
62c4b672f6 | |
|
d618fabe78 | |
|
6d46d957bd | |
|
c29468941b | |
|
fe63b64d8f | |
|
931e0cb3a8 | |
|
299a4f4630 | |
|
5dff1e89b2 | |
|
0ed625f186 | |
|
5c7bd14b41 | |
|
b553dfa607 | |
|
2a45af895c | |
|
f3982dc8c6 | |
|
e6a353e475 | |
|
5abcf3b157 | |
|
f2389da024 | |
|
44b07879b0 | |
|
1bf4682b46 | |
|
cf61e5b682 | |
|
2f93524063 | |
|
fa677092f8 | |
|
6acbef94e6 | |
|
bd0bc1e2b7 | |
|
f8544ffaf6 | |
|
3217575f4f | |
|
c3ad697a80 | |
|
f352045055 | |
|
3b967a9a3e | |
|
f109df671c | |
|
6dedd275cf | |
|
44f12239ff | |
|
abb14f5ed9 | |
|
563662054c | |
|
96ba7938de | |
|
cd605c4f5d | |
|
49f0948e51 | |
|
34ac91c707 | |
|
9966c14e16 | |
|
faf02a9888 | |
|
9ba82e3b63 | |
|
47ae16c167 | |
|
ff626374ae | |
|
de36b214dc | |
|
f00bc89caa | |
|
05d0da2e3d | |
|
78ea3b9914 | |
|
4a323b0f96 | |
|
2c23c9e971 | |
|
58d27c4011 | |
|
a70ae0cb2e | |
|
679409fad2 | |
|
04b4009dbf | |
|
6e4eebce20 | |
|
f5987ef8f4 | |
|
e7475441bd | |
|
3f336b3a24 | |
|
789e6e0f5f | |
|
7ba7d6146f | |
|
ae26217328 | |
|
5acd6a6598 | |
|
141858d235 | |
|
b2594a567c | |
|
bafa427a0d | |
|
035d0ad679 | |
|
ed6a42f264 | |
|
0ec2b69d1e | |
|
2b177e6ab5 | |
|
77fbae7b1e | |
|
f9833ba753 | |
|
915cabe5b1 | |
|
522d425a06 | |
|
cb1677b0a8 | |
|
1282bab31e | |
|
caa7f36c30 | |
|
3b89760d41 | |
|
5ef6ca1263 | |
|
f1b0839d16 | |
|
ccbff2c5e4 | |
|
15ce8f9b56 | |
|
d1f27e3278 | |
|
a1c25e241b | |
|
1722848651 | |
|
4883ab47d8 | |
|
af9d3da336 | |
|
30f4e692d8 | |
|
b3c67b6ab3 | |
|
a853b85514 | |
|
49aae786fb | |
|
3b6204daec | |
|
ab50012460 | |
|
3176be5327 | |
|
c9a7bb15e3 | |
|
fb11280b66 | |
|
270a34c6b8 | |
|
4c416b434c | |
|
48bb2a383c | |
|
b5a6a70134 | |
|
4b59b65ebe | |
|
56284e36fa | |
|
95d69e27b3 | |
|
e24a6347a1 | |
|
3001cf9b0f | |
|
963b01e66d | |
|
4bdd384544 | |
|
db504946d1 | |
|
d558bb1366 | |
|
107663a9d1 | |
|
a25af4854a | |
|
1864a3fa57 | |
|
0c314ab77c | |
|
f74eda06bd | |
|
291b4ae7d4 | |
|
1a804ee675 | |
|
0b889b3a81 | |
|
1d3db126ac | |
|
64f57fdcd4 | |
|
c661ab20a4 | |
|
84af1aec01 | |
|
88a204dc27 | |
|
d41cea270e | |
|
54c018ff85 | |
|
4314ef7003 | |
|
059b54a298 | |
|
af4955dbde | |
|
a53b4a84be | |
|
6fde2a0087 | |
|
054d674deb | |
|
c15e3a0f5b | |
|
2f0617b379 | |
|
78bc5a85de | |
|
544ae0e0ff | |
|
c544918d65 | |
|
6e87a36995 | |
|
5237e935d5 | |
|
eef4e159ed | |
|
43274eb6d0 | |
|
51f0d260f0 | |
|
25f6a87bd2 | |
|
cb8c671d70 | |
|
81c7429deb | |
|
d7b43bccd5 | |
|
1c5cca29f1 | |
|
8fad269b30 | |
|
d6b4c3dc5d |
3
.flake8
3
.flake8
|
@ -1,3 +0,0 @@
|
|||
[flake8]
|
||||
max-line-length = 88
|
||||
exclude = .venv/*,venv/*,.git,__pycache__
|
|
@ -0,0 +1,4 @@
|
|||
name: "CodeQL config"
|
||||
|
||||
paths-ignore:
|
||||
- tests
|
|
@ -0,0 +1,92 @@
|
|||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: "Build, lint, and test"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install uv and set the python version
|
||||
uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Test with pytest
|
||||
run: uv run cov --frozen
|
||||
|
||||
- name: Run E2E tests with behave
|
||||
run: uv run e2e --frozen
|
||||
|
||||
- if: matrix.python-version == '3.13'
|
||||
name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
with:
|
||||
flags: unittests # optional
|
||||
name: coverage # optional
|
||||
fail_ci_if_error: true # optional (default = false)
|
||||
verbose: true # optional (default = false)
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Install uv and set the python version
|
||||
uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Install uv and set the python version
|
||||
uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
ignore-nothing-to-cache: true
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3
|
||||
with:
|
||||
languages: python
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3
|
|
@ -12,11 +12,14 @@ on:
|
|||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Merge
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
container: [ "python:3.8", "python:3.9", "python:3.10" ]
|
||||
container:
|
||||
image: ${{ matrix.container }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache virtualenvironment
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.venv
|
||||
key: ${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
|
||||
|
||||
- name: Upgrade pip
|
||||
run: pip install --upgrade pip
|
||||
|
||||
- name: Create and activate Virtualenv
|
||||
run: |
|
||||
pip install virtualenv
|
||||
[ ! -d ".venv" ] && virtualenv .venv
|
||||
. .venv/bin/activate
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run black formatter check
|
||||
run: black --check .
|
||||
|
||||
- name: Run flake8 formatter check
|
||||
run: flake8 .
|
||||
|
||||
- name: Run isort formatter check
|
||||
run: isort .
|
||||
|
||||
- name: Test with pytest
|
||||
run: coverage run --omit="*/test*" -m pytest
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@398b9de041a7e69750d45077b10c5912201a3466
|
||||
with:
|
||||
flags: unittests # optional
|
||||
name: coverage # optional
|
||||
fail_ci_if_error: true # optional (default = false)
|
||||
verbose: true # optional (default = false)
|
||||
|
||||
- name: Run E2E tests with behave
|
||||
run: |
|
||||
cp test-harness/features/evaluation.feature tests/features/
|
||||
behave tests/features/
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
|
@ -1,78 +0,0 @@
|
|||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master, main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
container: [ "python:3.8", "python:3.9", "python:3.10", "python:3.11" ]
|
||||
container:
|
||||
image: ${{ matrix.container }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache virtualenvironment
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.venv
|
||||
key: ${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
|
||||
|
||||
- name: Upgrade pip
|
||||
run: pip install --upgrade pip
|
||||
|
||||
- name: Create and activate Virtualenv
|
||||
run: |
|
||||
pip install virtualenv
|
||||
[ ! -d ".venv" ] && virtualenv .venv
|
||||
. .venv/bin/activate
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run black formatter check
|
||||
run: black --check .
|
||||
|
||||
- name: Run flake8 formatter check
|
||||
run: flake8 .
|
||||
|
||||
- name: Run isort formatter check
|
||||
run: isort .
|
||||
|
||||
- name: Test with pytest
|
||||
run: coverage run --omit="*/test*" -m pytest
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@398b9de041a7e69750d45077b10c5912201a3466
|
||||
with:
|
||||
flags: unittests # optional
|
||||
name: coverage # optional
|
||||
fail_ci_if_error: true # optional (default = false)
|
||||
verbose: true # optional (default = false)
|
||||
|
||||
- name: Run E2E tests with behave
|
||||
run: |
|
||||
cp test-harness/features/evaluation.feature tests/features/
|
||||
behave tests/features/
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
|
@ -10,72 +10,50 @@ on:
|
|||
branches:
|
||||
- main
|
||||
|
||||
permissions: # added using https://github.com/step-security/secure-workflows
|
||||
permissions: # added using https://github.com/step-security/secure-workflows
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
permissions:
|
||||
contents: write # for google-github-actions/release-please-action to create release commit
|
||||
pull-requests: write # for google-github-actions/release-please-action to create release PR
|
||||
contents: write # for googleapis/release-please-action to create release commit
|
||||
pull-requests: write # for googleapis/release-please-action to create release PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: google-github-actions/release-please-action@v3
|
||||
- uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3
|
||||
id: release
|
||||
with:
|
||||
command: manifest
|
||||
token: ${{secrets.GITHUB_TOKEN}}
|
||||
token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}}
|
||||
default-branch: main
|
||||
signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>"
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
release_tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
permissions:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing to pypi
|
||||
id-token: write
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
container:
|
||||
image: "python:3.11"
|
||||
if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Cache virtualenvironment
|
||||
uses: actions/cache@v3
|
||||
- name: Install uv and set the python version
|
||||
uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6
|
||||
with:
|
||||
path: ~/.venv
|
||||
key: ${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
|
||||
|
||||
- name: Upgrade pip
|
||||
run: pip install --upgrade pip
|
||||
|
||||
- name: Create and activate Virtualenv
|
||||
run: |
|
||||
pip install virtualenv
|
||||
[ ! -d ".venv" ] && virtualenv .venv
|
||||
. .venv/bin/activate
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Install pypa/build
|
||||
run: >-
|
||||
python -m
|
||||
pip install
|
||||
build
|
||||
--user
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: >-
|
||||
python -m
|
||||
build
|
||||
--sdist
|
||||
--wheel
|
||||
--outdir dist/
|
||||
.
|
||||
run: uv build
|
||||
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@ -1,3 +1,3 @@
|
|||
[submodule "test-harness"]
|
||||
path = test-harness
|
||||
url = https://github.com/open-feature/test-harness.git
|
||||
[submodule "spec"]
|
||||
path = spec
|
||||
url = https://github.com/open-feature/spec.git
|
||||
|
|
|
@ -1,27 +1,22 @@
|
|||
default_stages: [ commit ]
|
||||
default_stages: [pre-commit]
|
||||
repos:
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.9.2
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.4
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ["--profile", "black", "--filter-files"]
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.9
|
||||
- id: ruff-check
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: trailing-whitespace
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.9.2
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.17.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [ "flake8-print", "flake8-builtins", "flake8-functions==0.0.4" ]
|
||||
- id: mypy
|
||||
files: openfeature
|
||||
|
|
|
@ -1 +1 @@
|
|||
{".":"0.2.0"}
|
||||
{".":"0.8.2"}
|
449
CHANGELOG.md
449
CHANGELOG.md
|
@ -1,5 +1,454 @@
|
|||
# Changelog
|
||||
|
||||
## [0.8.2](https://github.com/open-feature/python-sdk/compare/v0.8.1...v0.8.2) (2025-07-30)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* merge transaction context into hook context evaluation context ([#521](https://github.com/open-feature/python-sdk/issues/521)) ([#523](https://github.com/open-feature/python-sdk/issues/523)) ([a5b3aa9](https://github.com/open-feature/python-sdk/commit/a5b3aa9c5213dda311068695f9209282f5faaff5))
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* starting migration to uv ([#512](https://github.com/open-feature/python-sdk/issues/512)) ([fb47cbb](https://github.com/open-feature/python-sdk/commit/fb47cbb2a51da9154adf977aad0b16575d227c33))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** pin astral-sh/setup-uv action to bd01e18 ([#514](https://github.com/open-feature/python-sdk/issues/514)) ([6da7890](https://github.com/open-feature/python-sdk/commit/6da7890ac6488bccf640f74bdc530fa9ce8bbec3))
|
||||
* **deps:** update actions/setup-python digest to a26af69 ([#489](https://github.com/open-feature/python-sdk/issues/489)) ([ad69f2c](https://github.com/open-feature/python-sdk/commit/ad69f2c55f3c8170a8a53981238130eb106207ba))
|
||||
* **deps:** update astral-sh/setup-uv digest to 7edac99 ([#524](https://github.com/open-feature/python-sdk/issues/524)) ([5652c0c](https://github.com/open-feature/python-sdk/commit/5652c0c457cc5a524e91405f3b229cf245ae4531))
|
||||
* **deps:** update codecov/codecov-action action to v5.4.2 ([#486](https://github.com/open-feature/python-sdk/issues/486)) ([798ac8d](https://github.com/open-feature/python-sdk/commit/798ac8ded00b8509068003367f36e6c04c574cbc))
|
||||
* **deps:** update codecov/codecov-action action to v5.4.3 ([#497](https://github.com/open-feature/python-sdk/issues/497)) ([7bb0f5e](https://github.com/open-feature/python-sdk/commit/7bb0f5e499ff8e0985b24696d0680251c90af32b))
|
||||
* **deps:** update github/codeql-action digest to 181d5ee ([#517](https://github.com/open-feature/python-sdk/issues/517)) ([a04e52c](https://github.com/open-feature/python-sdk/commit/a04e52c0224a6c1c269218df050ce7a56076211d))
|
||||
* **deps:** update github/codeql-action digest to 28deaed ([#488](https://github.com/open-feature/python-sdk/issues/488)) ([e0de4b2](https://github.com/open-feature/python-sdk/commit/e0de4b2faa109454a8079b934320f1c2b2b2b06e))
|
||||
* **deps:** update github/codeql-action digest to 39edc49 ([#515](https://github.com/open-feature/python-sdk/issues/515)) ([21ef53a](https://github.com/open-feature/python-sdk/commit/21ef53a156b17ce24db79c75b5bbfeaf2bd77f01))
|
||||
* **deps:** update github/codeql-action digest to 60168ef ([#492](https://github.com/open-feature/python-sdk/issues/492)) ([8aedfe8](https://github.com/open-feature/python-sdk/commit/8aedfe81ef67af3210ea9921e6b364fdd21ef8ac))
|
||||
* **deps:** update github/codeql-action digest to ce28f5b ([#508](https://github.com/open-feature/python-sdk/issues/508)) ([4628c24](https://github.com/open-feature/python-sdk/commit/4628c24f5c94821aecb06388703173bd5a8efc30))
|
||||
* **deps:** update github/codeql-action digest to fca7ace ([#505](https://github.com/open-feature/python-sdk/issues/505)) ([347517a](https://github.com/open-feature/python-sdk/commit/347517a7ccaf145a940fc6e2a37a8d1df621f3a3))
|
||||
* **deps:** update github/codeql-action digest to ff0a06e ([#498](https://github.com/open-feature/python-sdk/issues/498)) ([c722cf0](https://github.com/open-feature/python-sdk/commit/c722cf0239f2b9b95a1214b99447a9316c2c73d8))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.10 ([#496](https://github.com/open-feature/python-sdk/issues/496)) ([1dd8b29](https://github.com/open-feature/python-sdk/commit/1dd8b294930ee94e9c27a7bad4e43fda76bb1f9d))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.11 ([#499](https://github.com/open-feature/python-sdk/issues/499)) ([abb3137](https://github.com/open-feature/python-sdk/commit/abb31377790f95e4900e92f57050a4a12dc9f311))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.12 ([#501](https://github.com/open-feature/python-sdk/issues/501)) ([8dfa88c](https://github.com/open-feature/python-sdk/commit/8dfa88cf8aacada8b8b20803a51667b586e2182a))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.13 ([#507](https://github.com/open-feature/python-sdk/issues/507)) ([a21413b](https://github.com/open-feature/python-sdk/commit/a21413bd5069a9fd32378e197ae7709b34f001a5))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.5 ([#484](https://github.com/open-feature/python-sdk/issues/484)) ([95be943](https://github.com/open-feature/python-sdk/commit/95be943d33b9cfca137f94e5a1ad52bc562e4b4a))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.6 ([#487](https://github.com/open-feature/python-sdk/issues/487)) ([7fe752d](https://github.com/open-feature/python-sdk/commit/7fe752d8fd6148a38d53e5c7dfc071a6c8121c85))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.7 ([#490](https://github.com/open-feature/python-sdk/issues/490)) ([f4f9a12](https://github.com/open-feature/python-sdk/commit/f4f9a12081871f4797dc0e649219dc7be58d4116))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.8 ([#491](https://github.com/open-feature/python-sdk/issues/491)) ([1f16955](https://github.com/open-feature/python-sdk/commit/1f169551e37f286597400e5cb2c171a60a38436e))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.9 ([#493](https://github.com/open-feature/python-sdk/issues/493)) ([42fed6b](https://github.com/open-feature/python-sdk/commit/42fed6b2001b9056ed9bc7a390213206829a7b99))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.12.0 ([#510](https://github.com/open-feature/python-sdk/issues/510)) ([d21d9db](https://github.com/open-feature/python-sdk/commit/d21d9db90ae6f5f15f1aaf25a4e5d0669dbe1d96))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.12.1 ([#513](https://github.com/open-feature/python-sdk/issues/513)) ([7783a8b](https://github.com/open-feature/python-sdk/commit/7783a8b6c798246fc861fcb83e24427ea0f43e5f))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.12.2 ([#518](https://github.com/open-feature/python-sdk/issues/518)) ([288bd6b](https://github.com/open-feature/python-sdk/commit/288bd6bb34f2d5e857ae5b2b17f02f79276049c5))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.12.4 ([#525](https://github.com/open-feature/python-sdk/issues/525)) ([90a193d](https://github.com/open-feature/python-sdk/commit/90a193d22c64b8b708300dd436a16b8c7d632686))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.16.0 ([#503](https://github.com/open-feature/python-sdk/issues/503)) ([87e4485](https://github.com/open-feature/python-sdk/commit/87e448593d723b8239244a198e54e6cb056d3f95))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.16.1 ([#509](https://github.com/open-feature/python-sdk/issues/509)) ([ac95c7a](https://github.com/open-feature/python-sdk/commit/ac95c7a5b72d56e78dcb8e3411178967b00c04d9))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.17.0 ([#526](https://github.com/open-feature/python-sdk/issues/526)) ([00cab65](https://github.com/open-feature/python-sdk/commit/00cab65315ff3bc38f26b7281d8b258e6ec0f47d))
|
||||
* switch build backend to uv ([#527](https://github.com/open-feature/python-sdk/issues/527)) ([a369890](https://github.com/open-feature/python-sdk/commit/a3698902b55a1c9d53d4a7db2e6b18e4f2e77c70))
|
||||
* use publish env ([d54d239](https://github.com/open-feature/python-sdk/commit/d54d239a2d55359d9b49aa534a4b53339bf26571))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* refine typing.Any type hints ([#504](https://github.com/open-feature/python-sdk/issues/504)) ([f95b27a](https://github.com/open-feature/python-sdk/commit/f95b27a25ae1dda7281c2039ec9060363de2703e))
|
||||
|
||||
## [0.8.1](https://github.com/open-feature/python-sdk/compare/v0.8.0...v0.8.1) (2025-04-09)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* add passthrough init to abstract provider ([#450](https://github.com/open-feature/python-sdk/issues/450)) ([088409e](https://github.com/open-feature/python-sdk/commit/088409ea5cdefef33f28fc4f45026fabac52377a))
|
||||
* fix cycle dependency between api and client ([#480](https://github.com/open-feature/python-sdk/issues/480)) ([3636a0d](https://github.com/open-feature/python-sdk/commit/3636a0d75f69712844a768cbc6c2f80fdcf6eb84))
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* add OTel utility function ([#451](https://github.com/open-feature/python-sdk/issues/451)) ([2d1ba85](https://github.com/open-feature/python-sdk/commit/2d1ba85c93cdd954f539d2872783b21683bd8b07))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* add codeowner file to be consistent with the rest of openfeature ([#477](https://github.com/open-feature/python-sdk/issues/477)) ([7a30ef9](https://github.com/open-feature/python-sdk/commit/7a30ef914b3180fc72be9a1d2072a8a288e8b54d))
|
||||
* **deps:** update actions/setup-python digest to 8d9ed9a ([#473](https://github.com/open-feature/python-sdk/issues/473)) ([a135911](https://github.com/open-feature/python-sdk/commit/a1359112e9c1d740bcca501cbb5aadd9da3602b6))
|
||||
* **deps:** update codecov/codecov-action action to v5.4.0 ([#456](https://github.com/open-feature/python-sdk/issues/456)) ([a666227](https://github.com/open-feature/python-sdk/commit/a666227f55b14d4d2b6e43b6487ac643b6893739))
|
||||
* **deps:** update github/codeql-action digest to 1b549b9 ([#470](https://github.com/open-feature/python-sdk/issues/470)) ([4eeab3b](https://github.com/open-feature/python-sdk/commit/4eeab3b6914bd947a63f8d3c5bb89b85b7c2ced1))
|
||||
* **deps:** update github/codeql-action digest to 45775bd ([#483](https://github.com/open-feature/python-sdk/issues/483)) ([5a2825b](https://github.com/open-feature/python-sdk/commit/5a2825b00db0653c6d0496ec7f4703f9125cbed7))
|
||||
* **deps:** update github/codeql-action digest to 5f8171a ([#467](https://github.com/open-feature/python-sdk/issues/467)) ([d69b759](https://github.com/open-feature/python-sdk/commit/d69b7594a956a49385ef3030c212624d628aec74))
|
||||
* **deps:** update github/codeql-action digest to 6bb031a ([#462](https://github.com/open-feature/python-sdk/issues/462)) ([0396592](https://github.com/open-feature/python-sdk/commit/0396592586b6f721754c18a46b6d2fee3c2f80e8))
|
||||
* **deps:** update github/codeql-action digest to b56ba49 ([#454](https://github.com/open-feature/python-sdk/issues/454)) ([613388d](https://github.com/open-feature/python-sdk/commit/613388ddde33b6ce5ff3a39760970297dfa83255))
|
||||
* **deps:** update github/codeql-action digest to fc7e4a0 ([#481](https://github.com/open-feature/python-sdk/issues/481)) ([1ae9fc2](https://github.com/open-feature/python-sdk/commit/1ae9fc2361f1671cee8c794f02c01eb6ca0b77a6))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.10.0 ([#463](https://github.com/open-feature/python-sdk/issues/463)) ([5fede4d](https://github.com/open-feature/python-sdk/commit/5fede4d4f0cb6e39f84e85c72c9a2dd13434bc78))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.0 ([#465](https://github.com/open-feature/python-sdk/issues/465)) ([d1eb3a0](https://github.com/open-feature/python-sdk/commit/d1eb3a08a8da75022788cc4b9ea7b7d95aec4e69))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.1 ([#468](https://github.com/open-feature/python-sdk/issues/468)) ([c07d3d6](https://github.com/open-feature/python-sdk/commit/c07d3d64677c2ce475b098580b5eba1dd7f95a2e))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.2 ([#469](https://github.com/open-feature/python-sdk/issues/469)) ([95e87c7](https://github.com/open-feature/python-sdk/commit/95e87c71fc835cde7f7528e974509438ab8f2dc3))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.3 ([#475](https://github.com/open-feature/python-sdk/issues/475)) ([2be2c06](https://github.com/open-feature/python-sdk/commit/2be2c06569d89309a70793bb14a82be91d2ccf20))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.4 ([#476](https://github.com/open-feature/python-sdk/issues/476)) ([8acc883](https://github.com/open-feature/python-sdk/commit/8acc88328836c70f168ca87b71f4c49a6dba9381))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.10 ([#461](https://github.com/open-feature/python-sdk/issues/461)) ([9057c6b](https://github.com/open-feature/python-sdk/commit/9057c6b3df6ca5dc9e429db231eb4427cce031ea))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.7 ([#453](https://github.com/open-feature/python-sdk/issues/453)) ([a5cb27b](https://github.com/open-feature/python-sdk/commit/a5cb27b67839d60ea631001759478b2e74b75f28))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.8 ([#457](https://github.com/open-feature/python-sdk/issues/457)) ([0c1a388](https://github.com/open-feature/python-sdk/commit/0c1a388ca121e232f5c36b4b7a550d541ae34e5b))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.9 ([#458](https://github.com/open-feature/python-sdk/issues/458)) ([9ce51eb](https://github.com/open-feature/python-sdk/commit/9ce51ebff5a896b818e241fd8e3c2dea2fee610c))
|
||||
* **deps:** update spec digest to 09aef37 ([#460](https://github.com/open-feature/python-sdk/issues/460)) ([547781f](https://github.com/open-feature/python-sdk/commit/547781fbd82d1e2ee8a17988d11da3875d6a73dd))
|
||||
* **deps:** update spec digest to 0cd553d ([#455](https://github.com/open-feature/python-sdk/issues/455)) ([fe99f08](https://github.com/open-feature/python-sdk/commit/fe99f08e9465e8d35dd2b187d8ac01eae98432b7))
|
||||
* **deps:** update spec digest to 130df3e ([#471](https://github.com/open-feature/python-sdk/issues/471)) ([9ced6bf](https://github.com/open-feature/python-sdk/commit/9ced6bf2d1c7e3b0f01d062564ee63e49254af00))
|
||||
* **deps:** update spec digest to 25c57ee ([#459](https://github.com/open-feature/python-sdk/issues/459)) ([40cbd82](https://github.com/open-feature/python-sdk/commit/40cbd82dda20604a7a7be00e6913710d4a1ab56f))
|
||||
* **deps:** update spec digest to 27e4461 ([#472](https://github.com/open-feature/python-sdk/issues/472)) ([490cd06](https://github.com/open-feature/python-sdk/commit/490cd068533bb5ad702adf71915b6e0ac49706d8))
|
||||
* **deps:** update spec digest to 54952f3 ([#447](https://github.com/open-feature/python-sdk/issues/447)) ([f907855](https://github.com/open-feature/python-sdk/commit/f907855966cf788a3522e7626c76bd050de59a7e))
|
||||
* **deps:** update spec digest to a69f748 ([#452](https://github.com/open-feature/python-sdk/issues/452)) ([95b33b3](https://github.com/open-feature/python-sdk/commit/95b33b39e6ef472264002322162e83665054d71b))
|
||||
* **deps:** update spec digest to aad6193 ([#464](https://github.com/open-feature/python-sdk/issues/464)) ([d15388b](https://github.com/open-feature/python-sdk/commit/d15388b542798f7703578927dc5013863a83efa1))
|
||||
* improve resolve details callable type hints ([#449](https://github.com/open-feature/python-sdk/issues/449)) ([31afa64](https://github.com/open-feature/python-sdk/commit/31afa6490f7c2fc7a553b69c56840d494a520836))
|
||||
* revert spec to commit 0cd553d ([#479](https://github.com/open-feature/python-sdk/issues/479)) ([0ebec53](https://github.com/open-feature/python-sdk/commit/0ebec538db4d1180bad05e89bb62db23ca606a27))
|
||||
* use existing submodule version for e2e tests ([#444](https://github.com/open-feature/python-sdk/issues/444)) ([5ae8571](https://github.com/open-feature/python-sdk/commit/5ae8571ccd5f30c0aef87b0bc7f1a08a65254df0))
|
||||
* use keyword arguments, validate test ([#446](https://github.com/open-feature/python-sdk/issues/446)) ([f29c450](https://github.com/open-feature/python-sdk/commit/f29c4506a6a13307ba95a9b450a1b19c328975b3))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* fix linting issue on the readme ([1198728](https://github.com/open-feature/python-sdk/commit/11987280ba53ba087b1792316acc920a81434630))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* replace exception raising with error flag resolution ([#474](https://github.com/open-feature/python-sdk/issues/474)) ([e61b69b](https://github.com/open-feature/python-sdk/commit/e61b69bb5079547c62a3ad51499326057db69e7a))
|
||||
|
||||
## [0.8.0](https://github.com/open-feature/python-sdk/compare/v0.7.5...v0.8.0) (2025-02-11)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* drop Python 3.8 support ([#441](https://github.com/open-feature/python-sdk/issues/441))
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* Add async functionality to providers ([#413](https://github.com/open-feature/python-sdk/issues/413)) ([86e7c07](https://github.com/open-feature/python-sdk/commit/86e7c07112cfa9fa6bec15cb7a47f8a675034b8b))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** pin dependencies ([#435](https://github.com/open-feature/python-sdk/issues/435)) ([38d13fa](https://github.com/open-feature/python-sdk/commit/38d13fa454e8b7d5a55a8e4e12dcbe4c37f70706))
|
||||
* **deps:** update github/codeql-action digest to 9e8d078 ([#440](https://github.com/open-feature/python-sdk/issues/440)) ([ba0213e](https://github.com/open-feature/python-sdk/commit/ba0213e701958a9962676646bec267a5c530184c))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.5 ([#439](https://github.com/open-feature/python-sdk/issues/439)) ([75b41dd](https://github.com/open-feature/python-sdk/commit/75b41dd0202e9651801d2144ceec2c16ebe4989f))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.6 ([#443](https://github.com/open-feature/python-sdk/issues/443)) ([37296dc](https://github.com/open-feature/python-sdk/commit/37296dc0b5b7450815b3b63d7877968fe07f06be))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.15.0 ([#430](https://github.com/open-feature/python-sdk/issues/430)) ([fe0fea1](https://github.com/open-feature/python-sdk/commit/fe0fea1f7328bb97c6985628be2f5c11bae13f22))
|
||||
* drop Python 3.8 support ([#441](https://github.com/open-feature/python-sdk/issues/441)) ([bcd1a38](https://github.com/open-feature/python-sdk/commit/bcd1a3807e635dcd80a7894859ae14d54a3dc485))
|
||||
* fix renovate syntax issue ([e705af4](https://github.com/open-feature/python-sdk/commit/e705af47b1b44705f0f0cca1846ccb97e820f042))
|
||||
* use centralized renovate config, downgrade release please ([#433](https://github.com/open-feature/python-sdk/issues/433)) ([ff52163](https://github.com/open-feature/python-sdk/commit/ff521630a1962f73a1d3f8e3fc65c8cdc691f54b))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* fix eval context link ([#438](https://github.com/open-feature/python-sdk/issues/438)) ([154d834](https://github.com/open-feature/python-sdk/commit/154d8345e7a65f3409c168a87d157df583fc8aa8))
|
||||
* fix links to the openfeature ecosystem page ([#432](https://github.com/open-feature/python-sdk/issues/432)) ([49edce2](https://github.com/open-feature/python-sdk/commit/49edce226996d7d27a6dd64a1ae45e0def9e9b29))
|
||||
|
||||
## [0.7.5](https://github.com/open-feature/python-sdk/compare/v0.7.4...v0.7.5) (2025-01-31)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
The signature of the `finally_after` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). To migrate, update any hook that implements the `finally_after` stage to accept `evaluation details` as the second argument.
|
||||
|
||||
* Add evaluation details to finally hook stage [#403](https://github.com/open-feature/python-sdk/issues/403) ([#423](https://github.com/open-feature/python-sdk/issues/423)) ([9e9bb5c](https://github.com/open-feature/python-sdk/commit/9e9bb5c6269cfa5d9c9ffc7141c6dc63e399cdca))
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* Finally hooks do not get called when the provider is not ready [#424](https://github.com/open-feature/python-sdk/issues/424) ([#425](https://github.com/open-feature/python-sdk/issues/425)) ([8f2caba](https://github.com/open-feature/python-sdk/commit/8f2cabaa32f595304ecd6964b6ae21909672ef4a))
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* Add evaluation details to finally hook stage [#403](https://github.com/open-feature/python-sdk/issues/403) ([#423](https://github.com/open-feature/python-sdk/issues/423)) ([9e9bb5c](https://github.com/open-feature/python-sdk/commit/9e9bb5c6269cfa5d9c9ffc7141c6dc63e399cdca))
|
||||
* Update test harness (add assertions) [#1467](https://github.com/open-feature/python-sdk/issues/1467) ([#415](https://github.com/open-feature/python-sdk/issues/415)) ([f559d1b](https://github.com/open-feature/python-sdk/commit/f559d1b27a096c585bd81f32f6472039c9ce5e03))
|
||||
* Update test harness (copy test files) [#1467](https://github.com/open-feature/python-sdk/issues/1467) ([#416](https://github.com/open-feature/python-sdk/issues/416)) ([192f7c4](https://github.com/open-feature/python-sdk/commit/192f7c40bd07616030e86ff2aba7e993390d4af4))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **config:** migrate config renovate.json ([26bc964](https://github.com/open-feature/python-sdk/commit/26bc9642270f9371170ce8ab2d9b938a0fca187e))
|
||||
* **config:** migrate renovate config ([#408](https://github.com/open-feature/python-sdk/issues/408)) ([26bc964](https://github.com/open-feature/python-sdk/commit/26bc9642270f9371170ce8ab2d9b938a0fca187e))
|
||||
* **deps:** update actions/setup-python digest to 4237552 ([#422](https://github.com/open-feature/python-sdk/issues/422)) ([9c2ed71](https://github.com/open-feature/python-sdk/commit/9c2ed71c6efdc2ece9ae89cf91f3b019c44b0033))
|
||||
* **deps:** update codecov/codecov-action action to v5.1.0 ([#401](https://github.com/open-feature/python-sdk/issues/401)) ([0459330](https://github.com/open-feature/python-sdk/commit/0459330cb91e9b28a15bdd380aec4c56c3b5d8df))
|
||||
* **deps:** update codecov/codecov-action action to v5.1.1 ([#402](https://github.com/open-feature/python-sdk/issues/402)) ([a6907d6](https://github.com/open-feature/python-sdk/commit/a6907d610e6dde1eecef56f25f3cc6a569b6eee4))
|
||||
* **deps:** update codecov/codecov-action action to v5.1.2 ([#405](https://github.com/open-feature/python-sdk/issues/405)) ([1c56480](https://github.com/open-feature/python-sdk/commit/1c564804afad474151489d695af7fa0409a768c6))
|
||||
* **deps:** update codecov/codecov-action action to v5.2.0 ([#418](https://github.com/open-feature/python-sdk/issues/418)) ([b69e81a](https://github.com/open-feature/python-sdk/commit/b69e81a63676240ef2abb98e96d2954a50f0c20a))
|
||||
* **deps:** update codecov/codecov-action action to v5.3.0 ([#420](https://github.com/open-feature/python-sdk/issues/420)) ([6af37b1](https://github.com/open-feature/python-sdk/commit/6af37b1c2bc2e10161673af5726932129ed02506))
|
||||
* **deps:** update codecov/codecov-action action to v5.3.1 ([#421](https://github.com/open-feature/python-sdk/issues/421)) ([e99e481](https://github.com/open-feature/python-sdk/commit/e99e481524ccfbea5bc7554d531f9c883dce6b5f))
|
||||
* **deps:** update googleapis/release-please-action action to v4 ([#428](https://github.com/open-feature/python-sdk/issues/428)) ([99905d5](https://github.com/open-feature/python-sdk/commit/99905d57f8b21a640f8f64f177679eee127ebba6))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.1 ([#398](https://github.com/open-feature/python-sdk/issues/398)) ([043385a](https://github.com/open-feature/python-sdk/commit/043385a8369e253a5e0ad1e184e980f8e8d7e5c7))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.2 ([#400](https://github.com/open-feature/python-sdk/issues/400)) ([2b6e210](https://github.com/open-feature/python-sdk/commit/2b6e210bc9dda72335e646fc60cde79b5bdd76c1))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.3 ([#404](https://github.com/open-feature/python-sdk/issues/404)) ([01ec388](https://github.com/open-feature/python-sdk/commit/01ec388d2d93fc87a4f2eca856cf507cbee35785))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.4 ([#406](https://github.com/open-feature/python-sdk/issues/406)) ([3296d3b](https://github.com/open-feature/python-sdk/commit/3296d3b229d41c7f879adfb7ab36e19de36617e4))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.6 ([#410](https://github.com/open-feature/python-sdk/issues/410)) ([7f9d422](https://github.com/open-feature/python-sdk/commit/7f9d422497a6d8392eee18ccfd12050eef7a2338))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.0 ([#411](https://github.com/open-feature/python-sdk/issues/411)) ([bc6e333](https://github.com/open-feature/python-sdk/commit/bc6e3332157788ec3f8037f68b9087e26a4634b5))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.1 ([#412](https://github.com/open-feature/python-sdk/issues/412)) ([cbace6a](https://github.com/open-feature/python-sdk/commit/cbace6a24c3fa091242aedd4f7c2e8de4332e463))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.2 ([#414](https://github.com/open-feature/python-sdk/issues/414)) ([9304292](https://github.com/open-feature/python-sdk/commit/9304292ea91580cf8cbfee7dbde922caa818189d))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.3 ([#419](https://github.com/open-feature/python-sdk/issues/419)) ([8f9cc7c](https://github.com/open-feature/python-sdk/commit/8f9cc7ca96a1210bab104e6342b4f7d1553bad3b))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.4 ([#426](https://github.com/open-feature/python-sdk/issues/426)) ([f726706](https://github.com/open-feature/python-sdk/commit/f72670689d25b82c8d54cacde3b1af179e0bc7e6))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.14.0 ([#407](https://github.com/open-feature/python-sdk/issues/407)) ([89d6997](https://github.com/open-feature/python-sdk/commit/89d6997b1fe04df82e0873de7e7a1ea1aca2b071))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.14.1 ([#409](https://github.com/open-feature/python-sdk/issues/409)) ([2c1840c](https://github.com/open-feature/python-sdk/commit/2c1840c87d00177d87b93135a38a7df98a9f6c0b))
|
||||
|
||||
## [0.7.4](https://github.com/open-feature/python-sdk/compare/v0.7.3...v0.7.4) (2024-11-25)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.8.0 ([#395](https://github.com/open-feature/python-sdk/issues/395)) ([cd737a9](https://github.com/open-feature/python-sdk/commit/cd737a9a6aeb6ff64050f759749aab96e73a8c34))
|
||||
|
||||
## [0.7.3](https://github.com/open-feature/python-sdk/compare/v0.7.2...v0.7.3) (2024-11-24)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* implement transaction context ([#389](https://github.com/open-feature/python-sdk/issues/389)) ([9b97130](https://github.com/open-feature/python-sdk/commit/9b97130908c5ec07580d66e0f2aad9adf1607f53))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** update codecov/codecov-action action to v5 ([#386](https://github.com/open-feature/python-sdk/issues/386)) ([6cd570f](https://github.com/open-feature/python-sdk/commit/6cd570f64cb50473e0a046d5928e114157598d78))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.2 ([#388](https://github.com/open-feature/python-sdk/issues/388)) ([e72e329](https://github.com/open-feature/python-sdk/commit/e72e329a2acda0a0399ef2faa04c9b7f3b29bb65))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.3 ([#390](https://github.com/open-feature/python-sdk/issues/390)) ([3fb7cbc](https://github.com/open-feature/python-sdk/commit/3fb7cbc3ddcc9016ed51e890d9648f1f96284170))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.4 ([#391](https://github.com/open-feature/python-sdk/issues/391)) ([9759bfa](https://github.com/open-feature/python-sdk/commit/9759bfaa21ed6d6e5e84c5559304db9a49b61884))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.5 ([#392](https://github.com/open-feature/python-sdk/issues/392)) ([a652004](https://github.com/open-feature/python-sdk/commit/a652004fde72cb1dcf730f146db36d686589b642))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.6 ([#393](https://github.com/open-feature/python-sdk/issues/393)) ([24970b5](https://github.com/open-feature/python-sdk/commit/24970b5d0915f533d88e4cd0fb54bea8cd28cab0))
|
||||
* **deps:** update codecov/codecov-action action to v5.0.7 ([#394](https://github.com/open-feature/python-sdk/issues/394)) ([f024a6f](https://github.com/open-feature/python-sdk/commit/f024a6f3407b3e3ce8ea16a80982a7642c9f2f20))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.7.2 ([#381](https://github.com/open-feature/python-sdk/issues/381)) ([2d8fadf](https://github.com/open-feature/python-sdk/commit/2d8fadf40afb98e2915b645278358fca367447d7))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.7.3 ([#384](https://github.com/open-feature/python-sdk/issues/384)) ([1eb6f4c](https://github.com/open-feature/python-sdk/commit/1eb6f4c6558f3dd20cb2389f9650a6476ca20fcb))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.7.4 ([#387](https://github.com/open-feature/python-sdk/issues/387)) ([3d77f24](https://github.com/open-feature/python-sdk/commit/3d77f2410751646380f58a77cad0fd5851da30b5))
|
||||
|
||||
## [0.7.2](https://github.com/open-feature/python-sdk/compare/v0.7.1...v0.7.2) (2024-10-24)
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* add Python 3.13 ([#364](https://github.com/open-feature/python-sdk/issues/364)) ([ca4d589](https://github.com/open-feature/python-sdk/commit/ca4d589456a7c3ca1a8ba448592687c7909bf75d))
|
||||
* **deps:** update codecov/codecov-action action to v4.6.0 ([#371](https://github.com/open-feature/python-sdk/issues/371)) ([45d2a6d](https://github.com/open-feature/python-sdk/commit/45d2a6daca1ed6073fa9510a047085230b9d99c1))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.7 ([#357](https://github.com/open-feature/python-sdk/issues/357)) ([a691480](https://github.com/open-feature/python-sdk/commit/a6914808b8ed4338d332b333a734fb441b09d3d8))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.0 ([#359](https://github.com/open-feature/python-sdk/issues/359)) ([e538f86](https://github.com/open-feature/python-sdk/commit/e538f86e76958a40400e41838ee3fd81a4f11dd9))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.1 ([#360](https://github.com/open-feature/python-sdk/issues/360)) ([90d417d](https://github.com/open-feature/python-sdk/commit/90d417dbd3940b99dc5d9fe945f351f0296a4058))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.2 ([#361](https://github.com/open-feature/python-sdk/issues/361)) ([8e6a530](https://github.com/open-feature/python-sdk/commit/8e6a530040f122cdd1861dce883aebb725542932))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.3 ([#363](https://github.com/open-feature/python-sdk/issues/363)) ([6e316a2](https://github.com/open-feature/python-sdk/commit/6e316a216a50848959154f25bfcac17953910fcd))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.4 ([#365](https://github.com/open-feature/python-sdk/issues/365)) ([e57ff78](https://github.com/open-feature/python-sdk/commit/e57ff783064f1097609e807d9b781b252792b922))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.5 ([#366](https://github.com/open-feature/python-sdk/issues/366)) ([4959144](https://github.com/open-feature/python-sdk/commit/4959144a5016f60c13791154ef8a43108a8d8568))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.6 ([#367](https://github.com/open-feature/python-sdk/issues/367)) ([a334f20](https://github.com/open-feature/python-sdk/commit/a334f202515949f29ea4a47912881f84cbfc9a93))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.7 ([#369](https://github.com/open-feature/python-sdk/issues/369)) ([65b0f1a](https://github.com/open-feature/python-sdk/commit/65b0f1a374b692a1a55635b78355776524c2ac1a))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.8 ([#370](https://github.com/open-feature/python-sdk/issues/370)) ([dcdb56c](https://github.com/open-feature/python-sdk/commit/dcdb56c43f493aa3fd6c0e69d82b65c74b598a6f))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.6.9 ([#372](https://github.com/open-feature/python-sdk/issues/372)) ([d2c6b40](https://github.com/open-feature/python-sdk/commit/d2c6b401cd69cd1fb17aadfd00b4c37ff9e9e57d))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.7.0 ([#377](https://github.com/open-feature/python-sdk/issues/377)) ([157e1ba](https://github.com/open-feature/python-sdk/commit/157e1baddb69a51bd49f1f55ac381cffc50f4081))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.7.1 ([#380](https://github.com/open-feature/python-sdk/issues/380)) ([c3d70ec](https://github.com/open-feature/python-sdk/commit/c3d70ec4579d1af7556ae7cb903337de66131697))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.11.2 ([#362](https://github.com/open-feature/python-sdk/issues/362)) ([f38ff91](https://github.com/open-feature/python-sdk/commit/f38ff919bde09fa76b50992b8fb05ae3f2397936))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.12.0 ([#376](https://github.com/open-feature/python-sdk/issues/376)) ([00f026d](https://github.com/open-feature/python-sdk/commit/00f026d4f17b3062c992bd03b1e714447601b406))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.12.1 ([#378](https://github.com/open-feature/python-sdk/issues/378)) ([091656b](https://github.com/open-feature/python-sdk/commit/091656b6539a3fcb7746c8fd26de7eab6f988cb3))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.13.0 ([#379](https://github.com/open-feature/python-sdk/issues/379)) ([f5aaac0](https://github.com/open-feature/python-sdk/commit/f5aaac0525587d533bb15f3641e15411f978cf00))
|
||||
* **deps:** update pre-commit hook pre-commit/pre-commit-hooks to v5 ([#373](https://github.com/open-feature/python-sdk/issues/373)) ([7a6b3c7](https://github.com/open-feature/python-sdk/commit/7a6b3c78cc86e2704441c4d64974f4348e63234d))
|
||||
* **deps:** update python docker tag to v3.13 ([#375](https://github.com/open-feature/python-sdk/issues/375)) ([977cd6d](https://github.com/open-feature/python-sdk/commit/977cd6d4b7c7b44755409242db1e430758b5b955))
|
||||
|
||||
## [0.7.1](https://github.com/open-feature/python-sdk/compare/v0.7.0...v0.7.1) (2024-08-02)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* event handler methods are not thread-safe ([#329](https://github.com/open-feature/python-sdk/issues/329)) ([3217575](https://github.com/open-feature/python-sdk/commit/3217575f4f87587751e47707384c344c185b684c)), closes [#326](https://github.com/open-feature/python-sdk/issues/326)
|
||||
* make global hooks thread safe ([#331](https://github.com/open-feature/python-sdk/issues/331)) ([5abcf3b](https://github.com/open-feature/python-sdk/commit/5abcf3b157f0f1ef6655a64abf1229ab84ad190e))
|
||||
* remove exception logging during evaluation ([#347](https://github.com/open-feature/python-sdk/issues/347)) ([0ed625f](https://github.com/open-feature/python-sdk/commit/0ed625f18617472ac0e60a88e727223381d8d735))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** update codecov/codecov-action action to v4.3.1 ([#327](https://github.com/open-feature/python-sdk/issues/327)) ([f352045](https://github.com/open-feature/python-sdk/commit/f3520450557c71d8bfd7884c909114c27ba4e2e6))
|
||||
* **deps:** update codecov/codecov-action action to v4.4.0 ([#334](https://github.com/open-feature/python-sdk/issues/334)) ([6acbef9](https://github.com/open-feature/python-sdk/commit/6acbef94e67fa1c5da8f764a1d581870d92729aa))
|
||||
* **deps:** update codecov/codecov-action action to v4.4.1 ([#335](https://github.com/open-feature/python-sdk/issues/335)) ([fa67709](https://github.com/open-feature/python-sdk/commit/fa677092f894ed0ad00093391b799fb5a2adbab2))
|
||||
* **deps:** update codecov/codecov-action action to v4.5.0 ([#341](https://github.com/open-feature/python-sdk/issues/341)) ([e6a353e](https://github.com/open-feature/python-sdk/commit/e6a353e4754aa9443f3042b820bf167b6a66c944))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.10 ([#344](https://github.com/open-feature/python-sdk/issues/344)) ([2a45af8](https://github.com/open-feature/python-sdk/commit/2a45af895cc7dc7e15f94422a9de58d2b82db92b))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.3 ([#330](https://github.com/open-feature/python-sdk/issues/330)) ([f8544ff](https://github.com/open-feature/python-sdk/commit/f8544ffaf6abdee88d38e40c2dc493b36dad2c82))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.4 ([#333](https://github.com/open-feature/python-sdk/issues/333)) ([bd0bc1e](https://github.com/open-feature/python-sdk/commit/bd0bc1e2b7a28b1f1dcd50b63b61214131968925))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.5 ([#336](https://github.com/open-feature/python-sdk/issues/336)) ([2f93524](https://github.com/open-feature/python-sdk/commit/2f9352406301d0dfb804d04bf21039e87eeb01c5))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.6 ([#337](https://github.com/open-feature/python-sdk/issues/337)) ([cf61e5b](https://github.com/open-feature/python-sdk/commit/cf61e5b682481b4350d47af928f330bbbd93d7f1))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.7 ([#338](https://github.com/open-feature/python-sdk/issues/338)) ([1bf4682](https://github.com/open-feature/python-sdk/commit/1bf4682b466b998106fb94c7bbafdaa4a5e32289))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.8 ([#339](https://github.com/open-feature/python-sdk/issues/339)) ([44b0787](https://github.com/open-feature/python-sdk/commit/44b07879b08030c1356192ad4f69bc8b58c59914))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.9 ([#342](https://github.com/open-feature/python-sdk/issues/342)) ([f3982dc](https://github.com/open-feature/python-sdk/commit/f3982dc8c6faf5de6b86a406f8ecf2056d15026b))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.0 ([#346](https://github.com/open-feature/python-sdk/issues/346)) ([5c7bd14](https://github.com/open-feature/python-sdk/commit/5c7bd14b415336e990aced2bf2b12f6d2dd64b84))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.1 ([#348](https://github.com/open-feature/python-sdk/issues/348)) ([5dff1e8](https://github.com/open-feature/python-sdk/commit/5dff1e89b21542a16d602a541f76a52f8a0dbc4f))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.2 ([#349](https://github.com/open-feature/python-sdk/issues/349)) ([299a4f4](https://github.com/open-feature/python-sdk/commit/299a4f4630c18c8fc5a5bb1a55a1bcaa9a19fd8c))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.3 ([#350](https://github.com/open-feature/python-sdk/issues/350)) ([fe63b64](https://github.com/open-feature/python-sdk/commit/fe63b64d8fe90efc1433971aa7b1701ef8ae93c9))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.4 ([#352](https://github.com/open-feature/python-sdk/issues/352)) ([c294689](https://github.com/open-feature/python-sdk/commit/c29468941b946d6b8e355c3d60bc2e1f14faa959))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.5 ([#353](https://github.com/open-feature/python-sdk/issues/353)) ([6d46d95](https://github.com/open-feature/python-sdk/commit/6d46d957bdd8dd58bf11ab47689dbc8e19e80cf6))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.5.6 ([#356](https://github.com/open-feature/python-sdk/issues/356)) ([261aa41](https://github.com/open-feature/python-sdk/commit/261aa4168ef7aab4d8613af4f45df1a495018f2e))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.10.1 ([#345](https://github.com/open-feature/python-sdk/issues/345)) ([b553dfa](https://github.com/open-feature/python-sdk/commit/b553dfa607ce3c22d1369180c7b8a20291895ac0))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.11.0 ([#351](https://github.com/open-feature/python-sdk/issues/351)) ([931e0cb](https://github.com/open-feature/python-sdk/commit/931e0cb3a8515dcb46c37c3eb9fa2bc08d88eed6))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.11.1 ([#355](https://github.com/open-feature/python-sdk/issues/355)) ([62c4b67](https://github.com/open-feature/python-sdk/commit/62c4b672f67f2d8c9e98c5fab902542e5d2092b2))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* bind providers explicitly to a registry with attach/detach ([#324](https://github.com/open-feature/python-sdk/issues/324)) ([c3ad697](https://github.com/open-feature/python-sdk/commit/c3ad697a80ade72fb5cdee147ac5c11c38e6533f))
|
||||
|
||||
## [0.7.0](https://github.com/open-feature/python-sdk/compare/v0.6.1...v0.7.0) (2024-04-25)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* move AbstractProvider to openfeature.provider ([#314](https://github.com/open-feature/python-sdk/issues/314))
|
||||
* restrict exported names with __all__ ([#306](https://github.com/open-feature/python-sdk/issues/306))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* remove ProviderEvent.PROVIDER_FATAL ([#307](https://github.com/open-feature/python-sdk/issues/307)) ([de36b21](https://github.com/open-feature/python-sdk/commit/de36b214dcba717d3ff72cb5d9cc3d3c8de45461))
|
||||
* restrict exported names with __all__ ([#306](https://github.com/open-feature/python-sdk/issues/306)) ([34ac91c](https://github.com/open-feature/python-sdk/commit/34ac91c707103fa50e905c54148f09615c610c33))
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* update provider status when provider emits events ([#309](https://github.com/open-feature/python-sdk/issues/309)) ([9966c14](https://github.com/open-feature/python-sdk/commit/9966c14e16329f8d1e70b492b8785be497257a6b))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.3.5 ([#311](https://github.com/open-feature/python-sdk/issues/311)) ([47ae16c](https://github.com/open-feature/python-sdk/commit/47ae16c167fbb4bd8f92eaca3151afabb18a6752))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.3.6 ([#316](https://github.com/open-feature/python-sdk/issues/316)) ([49f0948](https://github.com/open-feature/python-sdk/commit/49f0948e5140151f655e6d34c15810092fda3510))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.3.7 ([#318](https://github.com/open-feature/python-sdk/issues/318)) ([5636620](https://github.com/open-feature/python-sdk/commit/563662054ce0707d9c752bcd639e9d296b9bce9e))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.0 ([#320](https://github.com/open-feature/python-sdk/issues/320)) ([abb14f5](https://github.com/open-feature/python-sdk/commit/abb14f5ed9b1b5e95ea90e6df0a67c9109bd465e))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.1 ([#321](https://github.com/open-feature/python-sdk/issues/321)) ([44f1223](https://github.com/open-feature/python-sdk/commit/44f12239ffe67e6f3a78dc68640bf56fd56fe8d9))
|
||||
* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.4.2 ([#323](https://github.com/open-feature/python-sdk/issues/323)) ([f109df6](https://github.com/open-feature/python-sdk/commit/f109df671c6b446219e52a575ddff70d74792ddf))
|
||||
* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.10.0 ([#322](https://github.com/open-feature/python-sdk/issues/322)) ([6dedd27](https://github.com/open-feature/python-sdk/commit/6dedd275cfe71fd76a772dea7a318312aac3d477))
|
||||
* **deps:** update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 ([#312](https://github.com/open-feature/python-sdk/issues/312)) ([faf02a9](https://github.com/open-feature/python-sdk/commit/faf02a98889585e3fbe0215e2a8963a39e626ff1))
|
||||
* update codecov/codecov-action action to v4 ([#317](https://github.com/open-feature/python-sdk/issues/317)) ([cd605c4](https://github.com/open-feature/python-sdk/commit/cd605c4f5d130b9823555a9bd465d6874b321701))
|
||||
* update renovate config ([#310](https://github.com/open-feature/python-sdk/issues/310)) ([ff62637](https://github.com/open-feature/python-sdk/commit/ff626374ae20311f090a9344aedbde7d37fb35fd))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* move AbstractProvider to openfeature.provider ([#314](https://github.com/open-feature/python-sdk/issues/314)) ([96ba793](https://github.com/open-feature/python-sdk/commit/96ba7938de554724edfc1670d4737f4c495f98a6))
|
||||
|
||||
## [0.6.1](https://github.com/open-feature/python-sdk/compare/v0.6.0...v0.6.1) (2024-03-26)
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* populate provider and client metadata in HookContext ([#302](https://github.com/open-feature/python-sdk/issues/302)) ([78ea3b9](https://github.com/open-feature/python-sdk/commit/78ea3b991499231f42efda41ba6f672e20cb346c))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* add keywords to pyproject.toml ([#305](https://github.com/open-feature/python-sdk/issues/305)) ([05d0da2](https://github.com/open-feature/python-sdk/commit/05d0da2e3df86833986618243e18b66218425db8))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* mark hook_support module as private/internal ([#303](https://github.com/open-feature/python-sdk/issues/303)) ([4a323b0](https://github.com/open-feature/python-sdk/commit/4a323b0f9622663c9c43c292364fa25062d70715))
|
||||
|
||||
## [0.6.0](https://github.com/open-feature/python-sdk/compare/v0.5.0...v0.6.0) (2024-03-22)
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* run error hooks if provider returns FlagResolutionDetails with non-empty error_code ([#291](https://github.com/open-feature/python-sdk/issues/291)) ([e747544](https://github.com/open-feature/python-sdk/commit/e7475441bd14323431fdf1850e643f5aaaa21abd))
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* implement provider events ([#278](https://github.com/open-feature/python-sdk/issues/278)) ([679409f](https://github.com/open-feature/python-sdk/commit/679409fad229d0e675be4a8ee2b3a13860f4e987))
|
||||
* implement provider status ([#288](https://github.com/open-feature/python-sdk/issues/288)) ([789e6e0](https://github.com/open-feature/python-sdk/commit/789e6e0f5fcf499604261afd918ed1e8844fa0a0))
|
||||
|
||||
|
||||
### 🧹 Chore
|
||||
|
||||
* add changelog sections ([#282](https://github.com/open-feature/python-sdk/issues/282)) ([141858d](https://github.com/open-feature/python-sdk/commit/141858d2359bf6bf439426b3ea4ba322f4b10421))
|
||||
* **deps:** update dependency coverage to v7.4.3 ([#280](https://github.com/open-feature/python-sdk/issues/280)) ([bafa427](https://github.com/open-feature/python-sdk/commit/bafa427a0da40711d327c435ab199286f68fb6b7))
|
||||
* **deps:** update dependency coverage to v7.4.4 ([#293](https://github.com/open-feature/python-sdk/issues/293)) ([f5987ef](https://github.com/open-feature/python-sdk/commit/f5987ef8f41892c9cad776d7716592ac0eac4719))
|
||||
* **deps:** update dependency pytest to v8.0.2 ([#281](https://github.com/open-feature/python-sdk/issues/281)) ([b2594a5](https://github.com/open-feature/python-sdk/commit/b2594a567c31e48a1ae675b855e84300201e8132))
|
||||
* **deps:** update dependency pytest to v8.1.0 ([#287](https://github.com/open-feature/python-sdk/issues/287)) ([7ba7d61](https://github.com/open-feature/python-sdk/commit/7ba7d6146f0f801cadfd7593dc6df4b7d4f488d4))
|
||||
* **deps:** update dependency pytest to v8.1.1 ([#289](https://github.com/open-feature/python-sdk/issues/289)) ([3f336b3](https://github.com/open-feature/python-sdk/commit/3f336b3a248dd8e75e162870d26a4b97c61f2ff6))
|
||||
* **deps:** update dependency pytest-mock to v3.13.0 ([#298](https://github.com/open-feature/python-sdk/issues/298)) ([04b4009](https://github.com/open-feature/python-sdk/commit/04b4009dbfd112307e17a6f9273e0118ad337fe1))
|
||||
* **deps:** update dependency pytest-mock to v3.14.0 ([#300](https://github.com/open-feature/python-sdk/issues/300)) ([a70ae0c](https://github.com/open-feature/python-sdk/commit/a70ae0cb2e5322cc6290dbe5be12f0a665cc0e86))
|
||||
* update mypy and ruff ([#296](https://github.com/open-feature/python-sdk/issues/296)) ([6e4eebc](https://github.com/open-feature/python-sdk/commit/6e4eebce2073aa792444ea9f28906b9c925ebd75))
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* add missing imports in provider dev example in README ([ae26217](https://github.com/open-feature/python-sdk/commit/ae26217328a5ca07722c5e12b01720606259d805))
|
||||
* add Missing Imports in Provider Dev Example in README ([#286](https://github.com/open-feature/python-sdk/issues/286)) ([ae26217](https://github.com/open-feature/python-sdk/commit/ae26217328a5ca07722c5e12b01720606259d805))
|
||||
* update spec version to 0.8.0 ([#299](https://github.com/open-feature/python-sdk/issues/299)) ([58d27c4](https://github.com/open-feature/python-sdk/commit/58d27c4011b4f7fd96cc7d1ba10f017c7a3db958))
|
||||
|
||||
|
||||
### 🔄 Refactoring
|
||||
|
||||
* improve Hook Hints typing ([#285](https://github.com/open-feature/python-sdk/issues/285)) ([5acd6a6](https://github.com/open-feature/python-sdk/commit/5acd6a6598fa45326ddafb0184d184cadea826d0))
|
||||
|
||||
## [0.5.0](https://github.com/open-feature/python-sdk/compare/v0.4.2...v0.5.0) (2024-02-20)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* add support for domains ([#271](https://github.com/open-feature/python-sdk/issues/271))
|
||||
|
||||
### Features
|
||||
|
||||
* add support for domains ([#271](https://github.com/open-feature/python-sdk/issues/271)) ([ed6a42f](https://github.com/open-feature/python-sdk/commit/ed6a42f264a6efc149642181bfc4c6de0eb83ce1))
|
||||
|
||||
## [0.4.2](https://github.com/open-feature/python-sdk/compare/v0.4.1...v0.4.2) (2024-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add FeatureProvider protocol ([#268](https://github.com/open-feature/python-sdk/issues/268)) ([caa7f36](https://github.com/open-feature/python-sdk/commit/caa7f36c309149bd8d91c214e85f382b026093f6))
|
||||
* improve logging setup ([#261](https://github.com/open-feature/python-sdk/issues/261)) ([ccbff2c](https://github.com/open-feature/python-sdk/commit/ccbff2c5e46f69274230fc5ddc3cfb90a283d013))
|
||||
* make return value not optional in provider API functions ([#270](https://github.com/open-feature/python-sdk/issues/270)) ([cb1677b](https://github.com/open-feature/python-sdk/commit/cb1677b0a826ad496f1ffa1074018f1400d84c80))
|
||||
* make specific fields in HookContext immutable ([#266](https://github.com/open-feature/python-sdk/issues/266)) ([3b89760](https://github.com/open-feature/python-sdk/commit/3b89760d4127a997dadbee920d0e066b2bf08e84))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Allow string values for `FlagEvaluationDetails.reason` and `FlagResolutionDetails.reason` ([#264](https://github.com/open-feature/python-sdk/issues/264)) ([5ef6ca1](https://github.com/open-feature/python-sdk/commit/5ef6ca1263d2cebdc7c16177fc182eccd56bae2f))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* document shutdown function ([#237](https://github.com/open-feature/python-sdk/issues/237)) ([95d69e2](https://github.com/open-feature/python-sdk/commit/95d69e27b3f6b9cb9f716ae4b2d5b0879c0253e3))
|
||||
* update supported spec version ([#269](https://github.com/open-feature/python-sdk/issues/269)) ([1282bab](https://github.com/open-feature/python-sdk/commit/1282bab31ea6a554911a9d37c4c4d3e14ffa5133))
|
||||
|
||||
## [0.4.1](https://github.com/open-feature/python-sdk/compare/v0.4.0...v0.4.1) (2023-11-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add PEP 561 py.typed marker file ([#232](https://github.com/open-feature/python-sdk/issues/232)) ([db50494](https://github.com/open-feature/python-sdk/commit/db504946d1aea7e653e5755d703cff3d52b455dd))
|
||||
* fix types for HookContext.{client,provider}_metadata ([#233](https://github.com/open-feature/python-sdk/issues/233)) ([4bdd384](https://github.com/open-feature/python-sdk/commit/4bdd384544c24f5d9942c1e6261689c6b8ceb7de))
|
||||
* replace str with enum value in InMemoryFlag definition ([#234](https://github.com/open-feature/python-sdk/issues/234)) ([963b01e](https://github.com/open-feature/python-sdk/commit/963b01e66d6ebe8062beaf3bfa0d034a312c037e))
|
||||
|
||||
## [0.4.0](https://github.com/open-feature/python-sdk/compare/v0.3.1...v0.4.0) (2023-11-01)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* raise error if the flag wasn't found using the in-memory provider ([#228](https://github.com/open-feature/python-sdk/issues/228))
|
||||
|
||||
### Features
|
||||
|
||||
* implement initialize/shutdown on provider registration ([#213](https://github.com/open-feature/python-sdk/issues/213)) ([84af1ae](https://github.com/open-feature/python-sdk/commit/84af1aec01241842289bce2beb35486153876706))
|
||||
* pass flag_metadata from resolution to evaluation details ([#212](https://github.com/open-feature/python-sdk/issues/212)) ([88a204d](https://github.com/open-feature/python-sdk/commit/88a204dc27c435f3b5faec231a07a96cb011518c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Hook methods should have default non-abstract implementations ([#216](https://github.com/open-feature/python-sdk/issues/216)) ([c661ab2](https://github.com/open-feature/python-sdk/commit/c661ab20a43ff4411b7f0847c71df886af87e7ed))
|
||||
* raise error if the flag wasn't found using the in-memory provider ([#228](https://github.com/open-feature/python-sdk/issues/228)) ([0c314ab](https://github.com/open-feature/python-sdk/commit/0c314ab77cd60d3347aea7f733d324a6228e8871))
|
||||
|
||||
## [0.3.1](https://github.com/open-feature/python-sdk/compare/v0.3.0...v0.3.1) (2023-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make openfeature an implicit namespace package ([#199](https://github.com/open-feature/python-sdk/issues/199)) ([c544918](https://github.com/open-feature/python-sdk/commit/c544918d65c2b0af621ec9e2261784e9a715dd9d))
|
||||
|
||||
## [0.3.0](https://github.com/open-feature/python-sdk/compare/v0.2.0...v0.3.0) (2023-09-25)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* rename top-level package to openfeature ([#192](https://github.com/open-feature/python-sdk/issues/192))
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* rename top-level package to openfeature ([#192](https://github.com/open-feature/python-sdk/issues/192)) ([51f0d26](https://github.com/open-feature/python-sdk/commit/51f0d260f02cce5ab673305f212770ffcfc0744f))
|
||||
|
||||
## [0.2.0](https://github.com/open-feature/python-sdk/compare/v0.1.0...v0.2.0) (2023-09-09)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
* @open-feature/sdk-python-maintainers @open-feature/maintainers
|
|
@ -4,23 +4,59 @@
|
|||
|
||||
### System Requirements
|
||||
|
||||
Python 3.8 and above are required.
|
||||
Python 3.9 and above are required.
|
||||
|
||||
### Target version(s)
|
||||
|
||||
Python 3.8 and above are supported by the SDK.
|
||||
Python 3.9 and above are supported by the SDK.
|
||||
|
||||
### Installation and Dependencies
|
||||
|
||||
A [`Makefile`](./Makefile) has been included in the project which should make it straightforward to start the project locally. We utilize virtual environments (see [`virtualenv`](https://docs.python.org/3/tutorial/venv.html)) in order to provide isolated development environments for the project. This reduces the risk of invalid or corrupt global packages. It also integrates nicely with Make, which will detect changes in the `requirements-dev.txt` file and update the virtual environment if any occur.
|
||||
We use [uv](https://github.com/astral-sh/uv) for fast Python package management and dependency resolution.
|
||||
|
||||
Run `make init` to initialize the project's virtual environment and install all dev dependencies.
|
||||
To install uv, follow the [installation guide](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/open-feature/python-sdk.git
|
||||
cd python-sdk
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
uv sync --frozen
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run tests with `make test`.
|
||||
Run tests:
|
||||
```bash
|
||||
uv run test --frozen
|
||||
```
|
||||
|
||||
### Coverage
|
||||
|
||||
Run tests with a coverage report:
|
||||
```bash
|
||||
uv run cov --frozen
|
||||
```
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
Run e2e tests with behave:
|
||||
```bash
|
||||
uv run e2e --frozen
|
||||
```
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Run pre-commit hooks
|
||||
```bash
|
||||
uv run precommit --frozen
|
||||
```
|
||||
|
||||
We use `pytest` for our unit testing, making use of `parametrized` to inject cases at scale.
|
||||
|
||||
### Integration tests
|
||||
|
||||
|
@ -55,7 +91,7 @@ git remote add fork https://github.com/YOUR_GITHUB_USERNAME/python-sdk.git
|
|||
Ensure your development environment is all set up by building and testing
|
||||
|
||||
```bash
|
||||
make
|
||||
uv run test --frozen
|
||||
```
|
||||
|
||||
To start working on a new feature or bugfix, create a new branch and start working on it.
|
||||
|
@ -107,4 +143,4 @@ on each other), the owner should try to get people aligned by:
|
|||
## Design Choices
|
||||
|
||||
As with other OpenFeature SDKs, python-sdk follows the
|
||||
[openfeature-specification](https://github.com/open-feature/spec).
|
||||
[openfeature-specification](https://github.com/open-feature/spec).
|
||||
|
|
40
Makefile
40
Makefile
|
@ -1,40 +0,0 @@
|
|||
VENV = . .venv/bin/activate
|
||||
|
||||
.PHONY: all
|
||||
all: lint test
|
||||
|
||||
.PHONY: init
|
||||
init: .venv
|
||||
|
||||
.venv: requirements-dev.txt
|
||||
test -d .venv || python -m virtualenv .venv
|
||||
$(VENV); pip install -Ur requirements-dev.txt
|
||||
|
||||
.PHONY: test
|
||||
test: .venv
|
||||
ifdef TEST
|
||||
$(VENV); pytest $(TEST)
|
||||
else
|
||||
$(VENV); pytest
|
||||
endif
|
||||
|
||||
test-harness:
|
||||
git submodule update --init
|
||||
|
||||
.PHONY: lint
|
||||
lint: .venv
|
||||
$(VENV); black .
|
||||
$(VENV); flake8 .
|
||||
$(VENV); isort .
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@rm -rf .venv
|
||||
@find -iname "*.pyc" -delete
|
||||
|
||||
.PHONY: e2e
|
||||
e2e: .venv test-harness
|
||||
# NOTE: only the evaluation feature is run for now
|
||||
cp test-harness/features/evaluation.feature tests/features/
|
||||
$(VENV); behave tests/features/
|
||||
rm tests/features/*.feature
|
|
@ -0,0 +1,506 @@
|
|||
<!-- markdownlint-disable MD033 -->
|
||||
<!-- x-hide-in-docs-start -->
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/white/openfeature-horizontal-white.svg" />
|
||||
<img align="center" alt="OpenFeature Logo" src="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/black/openfeature-horizontal-black.svg" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<h2 align="center">OpenFeature Python SDK</h2>
|
||||
|
||||
<!-- x-hide-in-docs-end -->
|
||||
<!-- The 'github-badges' class is used in the docs -->
|
||||
<p align="center" class="github-badges">
|
||||
|
||||
<a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
|
||||
<img alt="Specification" src="https://img.shields.io/static/v1?label=Specification&message=v0.8.0&color=red&style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
<!-- x-release-please-start-version -->
|
||||
|
||||
<a href="https://github.com/open-feature/python-sdk/releases/tag/v0.8.2">
|
||||
<img alt="Latest version" src="https://img.shields.io/static/v1?label=release&message=v0.8.2&color=blue&style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
<br/>
|
||||
<a href="https://github.com/open-feature/python-sdk/actions/workflows/merge.yml">
|
||||
<img alt="Build status" src="https://github.com/open-feature/python-sdk/actions/workflows/build.yml/badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://codecov.io/gh/open-feature/python-sdk">
|
||||
<img alt="Codecov" src="https://codecov.io/gh/open-feature/python-sdk/branch/main/graph/badge.svg?token=FQ1I444HB3" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.python.org/downloads/">
|
||||
<img alt="Min python version" src="https://img.shields.io/badge/python->=3.9-blue.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.repostatus.org/#wip">
|
||||
<img alt="Repo status" src="https://www.repostatus.org/badges/latest/wip.svg" />
|
||||
</a>
|
||||
</p>
|
||||
<!-- x-hide-in-docs-start -->
|
||||
|
||||
[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.
|
||||
|
||||
<!-- x-hide-in-docs-end -->
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.9+
|
||||
|
||||
### Install
|
||||
|
||||
<!---x-release-please-start-version-->
|
||||
|
||||
#### Pip install
|
||||
|
||||
```bash
|
||||
pip install openfeature-sdk==0.8.2
|
||||
```
|
||||
|
||||
#### requirements.txt
|
||||
|
||||
```bash
|
||||
openfeature-sdk==0.8.2
|
||||
```
|
||||
|
||||
```python
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
<!---x-release-please-end-->
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
from openfeature import api
|
||||
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
|
||||
|
||||
# flags defined in memory
|
||||
my_flags = {
|
||||
"v2_enabled": InMemoryFlag("on", {"on": True, "off": False})
|
||||
}
|
||||
|
||||
# configure a provider
|
||||
api.set_provider(InMemoryProvider(my_flags))
|
||||
|
||||
# create a client
|
||||
client = api.get_client()
|
||||
|
||||
# get a bool flag value
|
||||
flag_value = client.get_boolean_value("v2_enabled", False)
|
||||
print("Value: " + str(flag_value))
|
||||
```
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
| Status | Features | Description |
|
||||
|--------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|
||||
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|
||||
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|
||||
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
|
||||
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
|
||||
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|
||||
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|
||||
| ✅ | [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) |
|
||||
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
|
||||
|
||||
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
|
||||
|
||||
### Providers
|
||||
|
||||
[Providers](https://openfeature.dev/docs/reference/concepts/provider) are an abstraction between a flag management system and the OpenFeature SDK.
|
||||
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.
|
||||
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.
|
||||
|
||||
Once you've added a provider as a dependency, it can be registered with OpenFeature like this:
|
||||
|
||||
```python
|
||||
from openfeature import api
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
api.set_provider(NoOpProvider())
|
||||
open_feature_client = api.get_client()
|
||||
```
|
||||
|
||||
In some situations, it may be beneficial to register multiple providers in the same application.
|
||||
This is possible using [domains](#domains), which is covered in more detail below.
|
||||
|
||||
### Targeting
|
||||
|
||||
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.
|
||||
In OpenFeature, we refer to this as [targeting](https://openfeature.dev/specification/glossary#targeting).
|
||||
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).
|
||||
|
||||
```python
|
||||
from openfeature.api import (
|
||||
get_client,
|
||||
get_provider,
|
||||
set_provider,
|
||||
get_evaluation_context,
|
||||
set_evaluation_context,
|
||||
)
|
||||
|
||||
global_context = EvaluationContext(
|
||||
targeting_key="targeting_key1", attributes={"application": "value1"}
|
||||
)
|
||||
request_context = EvaluationContext(
|
||||
targeting_key="targeting_key2", attributes={"email": request.form['email']}
|
||||
)
|
||||
|
||||
## set global context
|
||||
set_evaluation_context(global_context)
|
||||
|
||||
# merge second context
|
||||
client = get_client(name="No-op Provider")
|
||||
client.get_string_value("email", "fallback", request_context)
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
[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.
|
||||
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.
|
||||
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.
|
||||
|
||||
Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level.
|
||||
|
||||
```python
|
||||
from openfeature.api import add_hooks
|
||||
from openfeature.flag_evaluation import FlagEvaluationOptions
|
||||
|
||||
# set global hooks at the API-level
|
||||
add_hooks([MyHook()])
|
||||
|
||||
# or configure them in the client
|
||||
client = OpenFeatureClient()
|
||||
client.add_hooks([MyHook()])
|
||||
|
||||
# or at the invocation-level
|
||||
options = FlagEvaluationOptions(hooks=[MyHook()])
|
||||
client.get_boolean_flag("my-flag", False, flag_evaluation_options=options)
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
The OpenFeature SDK logs to the `openfeature` logger using the `logging` package from the Python Standard Library.
|
||||
|
||||
### Domains
|
||||
|
||||
Clients can be assigned to a domain.
|
||||
A domain is a logical identifier which can be used to associate clients with a particular provider.
|
||||
If a domain has no associated provider, the global provider is used.
|
||||
|
||||
```python
|
||||
from openfeature import api
|
||||
|
||||
# Registering the default provider
|
||||
api.set_provider(MyProvider());
|
||||
# Registering a provider to a domain
|
||||
api.set_provider(MyProvider(), "my-domain");
|
||||
|
||||
# A client bound to the default provider
|
||||
default_client = api.get_client();
|
||||
# A client bound to the MyProvider provider
|
||||
domain_scoped_client = api.get_client("my-domain");
|
||||
```
|
||||
|
||||
Domains can be defined on a provider during registration.
|
||||
For more details, please refer to the [providers](#providers) section.
|
||||
|
||||
### Eventing
|
||||
|
||||
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.
|
||||
|
||||
Please refer to the documentation of the provider you're using to see what events are supported.
|
||||
|
||||
```python
|
||||
from openfeature import api
|
||||
from openfeature.provider import ProviderEvent
|
||||
|
||||
def on_provider_ready(event_details: EventDetails):
|
||||
print(f"Provider {event_details.provider_name} is ready")
|
||||
|
||||
api.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready)
|
||||
|
||||
client = api.get_client()
|
||||
|
||||
def on_provider_ready(event_details: EventDetails):
|
||||
print(f"Provider {event_details.provider_name} is ready")
|
||||
|
||||
client.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready)
|
||||
```
|
||||
|
||||
### Transaction Context Propagation
|
||||
|
||||
Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
|
||||
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).
|
||||
|
||||
You can implement a different transaction context propagator by implementing the `TransactionContextPropagator` class exported by the OpenFeature SDK.
|
||||
In most cases you can use `ContextVarsTransactionContextPropagator` as it works for `threads` and `asyncio` using [Context Variables](https://peps.python.org/pep-0567/).
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
from openfeature import api
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
|
||||
|
||||
# Initialize the Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# Set the transaction context propagator
|
||||
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
|
||||
|
||||
# Middleware to set the transaction context
|
||||
# You can call api.set_transaction_context anywhere you have information,
|
||||
# you want to have available in the code-paths below the current one.
|
||||
@app.before_request
|
||||
def set_request_transaction_context():
|
||||
ip = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header
|
||||
evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip})
|
||||
api.set_transaction_context(evaluation_context)
|
||||
|
||||
def create_response() -> str:
|
||||
# This method can be anywhere in our code.
|
||||
# The feature flag evaluation will automatically contain the transaction context merged with other context
|
||||
new_response = api.get_client().get_string_value("response-message", "Hello User!")
|
||||
return f"Message from server: {new_response}"
|
||||
|
||||
# Example route where we use the transaction context
|
||||
@app.route('/greeting')
|
||||
def some_endpoint():
|
||||
return create_response()
|
||||
```
|
||||
|
||||
This also works for asyncio based implementations e.g. FastApi as seen in the following example:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from openfeature import api
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
|
||||
|
||||
# Initialize the FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
# Set the transaction context propagator
|
||||
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
|
||||
|
||||
# Middleware to set the transaction context
|
||||
@app.middleware("http")
|
||||
async def set_request_transaction_context(request: Request, call_next):
|
||||
ip = request.headers.get("X-Forwarded-For", request.client.host)
|
||||
user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header
|
||||
evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip})
|
||||
api.set_transaction_context(evaluation_context)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
def create_response() -> str:
|
||||
# This method can be located anywhere in our code.
|
||||
# The feature flag evaluation will automatically include the transaction context merged with other context.
|
||||
new_response = api.get_client().get_string_value("response-message", "Hello User!")
|
||||
return f"Message from server: {new_response}"
|
||||
|
||||
# Example route where we use the transaction context
|
||||
@app.get('/greeting')
|
||||
async def some_endpoint():
|
||||
return create_response()
|
||||
```
|
||||
|
||||
### Asynchronous Feature Retrieval
|
||||
|
||||
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).
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openfeature import api
|
||||
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
|
||||
|
||||
my_flags = { "v2_enabled": InMemoryFlag("on", {"on": True, "off": False}) }
|
||||
api.set_provider(InMemoryProvider(my_flags))
|
||||
client = api.get_client()
|
||||
flag_value = await client.get_boolean_value_async("v2_enabled", False) # API calls are suffixed by _async
|
||||
|
||||
print("Value: " + str(flag_value))
|
||||
```
|
||||
|
||||
See the [develop a provider](#develop-a-provider) for how to support asynchronous functionality in providers.
|
||||
|
||||
### Shutdown
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
from openfeature import api
|
||||
|
||||
api.shutdown()
|
||||
```
|
||||
|
||||
## Extending
|
||||
|
||||
### Develop a provider
|
||||
|
||||
To develop a provider, you need to create a new project and include the OpenFeature SDK as a dependency.
|
||||
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.
|
||||
You’ll then need to write the provider by implementing the `AbstractProvider` class exported by the OpenFeature SDK.
|
||||
|
||||
```python
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails
|
||||
from openfeature.hook import Hook
|
||||
from openfeature.provider import AbstractProvider, Metadata
|
||||
|
||||
class MyProvider(AbstractProvider):
|
||||
def get_metadata(self) -> Metadata:
|
||||
...
|
||||
|
||||
def get_provider_hooks(self) -> List[Hook]:
|
||||
return []
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
...
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
...
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
...
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
...
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: Union[dict, list],
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[Union[dict, list]]:
|
||||
...
|
||||
```
|
||||
|
||||
Providers can also be extended to support async functionality.
|
||||
To support add asynchronous calls to a provider:
|
||||
|
||||
- Implement the `AbstractProvider` as shown above.
|
||||
- Define asynchronous calls for each data type.
|
||||
|
||||
```python
|
||||
class MyProvider(AbstractProvider):
|
||||
...
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
...
|
||||
|
||||
async def resolve_string_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
...
|
||||
|
||||
async def resolve_integer_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
...
|
||||
|
||||
async def resolve_float_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
...
|
||||
|
||||
async def resolve_object_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: Union[dict, list],
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[Union[dict, list]]:
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
> 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!
|
||||
|
||||
### Develop a hook
|
||||
|
||||
To develop a hook, you need to create a new project and include the OpenFeature SDK as a dependency.
|
||||
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.
|
||||
Implement your own hook by creating a hook that inherits from the `Hook` class.
|
||||
Any of the evaluation life-cycle stages (`before`/`after`/`error`/`finally_after`) can be override to add the desired business logic.
|
||||
|
||||
```python
|
||||
from openfeature.hook import Hook
|
||||
|
||||
class MyHook(Hook):
|
||||
def after(self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict):
|
||||
print("This runs after the flag has been evaluated")
|
||||
|
||||
```
|
||||
|
||||
> 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!
|
||||
|
||||
<!-- x-hide-in-docs-start -->
|
||||
|
||||
## ⭐️ Support the project
|
||||
|
||||
- Give this repo a ⭐️!
|
||||
- Follow us on social media:
|
||||
- Twitter: [@openfeature](https://twitter.com/openfeature)
|
||||
- LinkedIn: [OpenFeature](https://www.linkedin.com/company/openfeature/)
|
||||
- Join us on [Slack](https://cloud-native.slack.com/archives/C0344AANLA1)
|
||||
- For more, check out our [community page](https://openfeature.dev/community/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Interested in contributing? Great, we'd love your help! To get started, take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide.
|
||||
|
||||
### Thanks to everyone who has already contributed
|
||||
|
||||
<a href="https://github.com/open-feature/python-sdk/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=open-feature/python-sdk" alt="Pictures of the folks who have contributed to the project" />
|
||||
</a>
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
|
||||
<!-- x-hide-in-docs-end -->
|
|
@ -1,11 +0,0 @@
|
|||
try:
|
||||
from enum import StrEnum
|
||||
except ImportError:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""
|
||||
Backport StrEnum for Python <3.11
|
||||
"""
|
||||
|
||||
pass
|
|
@ -1,65 +0,0 @@
|
|||
import typing
|
||||
|
||||
from open_feature.client import OpenFeatureClient
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.exception import GeneralError
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.metadata import Metadata
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from open_feature.provider.provider import AbstractProvider
|
||||
|
||||
_provider: AbstractProvider = NoOpProvider()
|
||||
|
||||
_evaluation_context = EvaluationContext()
|
||||
|
||||
_hooks: typing.List[Hook] = []
|
||||
|
||||
|
||||
def get_client(
|
||||
name: typing.Optional[str] = None, version: typing.Optional[str] = None
|
||||
) -> OpenFeatureClient:
|
||||
return OpenFeatureClient(name=name, version=version, provider=_provider)
|
||||
|
||||
|
||||
def set_provider(provider: AbstractProvider):
|
||||
global _provider
|
||||
if provider is None:
|
||||
raise GeneralError(error_message="No provider")
|
||||
_provider = provider
|
||||
|
||||
|
||||
def get_provider() -> typing.Optional[AbstractProvider]:
|
||||
global _provider
|
||||
return _provider
|
||||
|
||||
|
||||
def get_provider_metadata() -> typing.Optional[Metadata]:
|
||||
global _provider
|
||||
return _provider.get_metadata()
|
||||
|
||||
|
||||
def get_evaluation_context() -> EvaluationContext:
|
||||
global _evaluation_context
|
||||
return _evaluation_context
|
||||
|
||||
|
||||
def set_evaluation_context(evaluation_context: EvaluationContext):
|
||||
global _evaluation_context
|
||||
if evaluation_context is None:
|
||||
raise GeneralError(error_message="No api level evaluation context")
|
||||
_evaluation_context = evaluation_context
|
||||
|
||||
|
||||
def add_hooks(hooks: typing.List[Hook]):
|
||||
global _hooks
|
||||
_hooks = _hooks + hooks
|
||||
|
||||
|
||||
def clear_hooks():
|
||||
global _hooks
|
||||
_hooks = []
|
||||
|
||||
|
||||
def get_hooks() -> typing.List[Hook]:
|
||||
global _hooks
|
||||
return _hooks
|
|
@ -1,398 +0,0 @@
|
|||
import logging
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
from open_feature import api
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.exception import (
|
||||
ErrorCode,
|
||||
GeneralError,
|
||||
OpenFeatureError,
|
||||
TypeMismatchError,
|
||||
)
|
||||
from open_feature.flag_evaluation import (
|
||||
FlagEvaluationDetails,
|
||||
FlagEvaluationOptions,
|
||||
FlagType,
|
||||
Reason,
|
||||
FlagResolutionDetails,
|
||||
)
|
||||
from open_feature.hook import Hook, HookContext
|
||||
from open_feature.hook.hook_support import (
|
||||
after_all_hooks,
|
||||
after_hooks,
|
||||
before_hooks,
|
||||
error_hooks,
|
||||
)
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from open_feature.provider.provider import AbstractProvider
|
||||
|
||||
GetDetailCallable = typing.Union[
|
||||
typing.Callable[
|
||||
[str, bool, typing.Optional[EvaluationContext]], FlagResolutionDetails[bool]
|
||||
],
|
||||
typing.Callable[
|
||||
[str, int, typing.Optional[EvaluationContext]], FlagResolutionDetails[int]
|
||||
],
|
||||
typing.Callable[
|
||||
[str, float, typing.Optional[EvaluationContext]], FlagResolutionDetails[float]
|
||||
],
|
||||
typing.Callable[
|
||||
[str, str, typing.Optional[EvaluationContext]], FlagResolutionDetails[str]
|
||||
],
|
||||
typing.Callable[
|
||||
[str, typing.Union[dict, list], typing.Optional[EvaluationContext]],
|
||||
FlagResolutionDetails[typing.Union[dict, list]],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientMetadata:
|
||||
name: str
|
||||
|
||||
|
||||
class OpenFeatureClient:
|
||||
def __init__(
|
||||
self,
|
||||
name: typing.Optional[str],
|
||||
version: typing.Optional[str],
|
||||
provider: AbstractProvider,
|
||||
context: typing.Optional[EvaluationContext] = None,
|
||||
hooks: typing.Optional[typing.List[Hook]] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.context = context or EvaluationContext()
|
||||
self.hooks = hooks or []
|
||||
self.provider = provider
|
||||
|
||||
def get_metadata(self):
|
||||
return ClientMetadata(name=self.name)
|
||||
|
||||
def add_hooks(self, hooks: typing.List[Hook]):
|
||||
self.hooks = self.hooks + hooks
|
||||
|
||||
def get_boolean_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> bool:
|
||||
return self.get_boolean_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
def get_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.BOOLEAN,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_string_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> str:
|
||||
return self.get_string_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
def get_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.STRING,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_integer_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> int:
|
||||
return self.get_integer_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
def get_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.INTEGER,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_float_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> float:
|
||||
return self.get_float_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
def get_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.FLOAT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_object_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[dict, list],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> dict:
|
||||
return self.get_object_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
def get_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[dict, list],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.OBJECT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: typing.Any,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
"""
|
||||
Evaluate the flag requested by the user from the clients provider.
|
||||
|
||||
:param flag_type: the type of the flag being returned
|
||||
:param flag_key: the string key of the selected flag
|
||||
:param default_value: backup value returned if no result found by the provider
|
||||
:param evaluation_context: Information for the purposes of flag evaluation
|
||||
:param flag_evaluation_options: Additional flag evaluation information
|
||||
:return: a FlagEvaluationDetails object with the fully evaluated flag from a
|
||||
provider
|
||||
"""
|
||||
|
||||
if evaluation_context is None:
|
||||
evaluation_context = EvaluationContext()
|
||||
|
||||
if flag_evaluation_options is None:
|
||||
flag_evaluation_options = FlagEvaluationOptions()
|
||||
|
||||
evaluation_hooks = flag_evaluation_options.hooks
|
||||
hook_hints = flag_evaluation_options.hook_hints
|
||||
|
||||
hook_context = HookContext(
|
||||
flag_key=flag_key,
|
||||
flag_type=flag_type,
|
||||
default_value=default_value,
|
||||
evaluation_context=evaluation_context,
|
||||
client_metadata=None,
|
||||
provider_metadata=None,
|
||||
)
|
||||
# Hooks need to be handled in different orders at different stages
|
||||
# in the flag evaluation
|
||||
# before: API, Client, Invocation, Provider
|
||||
merged_hooks = (
|
||||
api.get_hooks()
|
||||
+ self.hooks
|
||||
+ evaluation_hooks
|
||||
+ self.provider.get_provider_hooks()
|
||||
)
|
||||
# after, error, finally: Provider, Invocation, Client, API
|
||||
reversed_merged_hooks = merged_hooks[:]
|
||||
reversed_merged_hooks.reverse()
|
||||
|
||||
try:
|
||||
# https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md
|
||||
# Any resulting evaluation context from a before hook will overwrite
|
||||
# duplicate fields defined globally, on the client, or in the invocation.
|
||||
# Requirement 3.2.2, 4.3.4: API.context->client.context->invocation.context
|
||||
invocation_context = before_hooks(
|
||||
flag_type, hook_context, merged_hooks, hook_hints
|
||||
)
|
||||
invocation_context = invocation_context.merge(ctx2=evaluation_context)
|
||||
|
||||
# Requirement 3.2.2 merge: API.context->client.context->invocation.context
|
||||
merged_context = (
|
||||
api.get_evaluation_context()
|
||||
.merge(self.context)
|
||||
.merge(invocation_context)
|
||||
)
|
||||
|
||||
flag_evaluation = self._create_provider_evaluation(
|
||||
flag_type,
|
||||
flag_key,
|
||||
default_value,
|
||||
merged_context,
|
||||
)
|
||||
|
||||
after_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
flag_evaluation,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
|
||||
return flag_evaluation
|
||||
|
||||
except OpenFeatureError as err:
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=err.error_code,
|
||||
error_message=err.error_message,
|
||||
)
|
||||
# Catch any type of exception here since the user can provide any exception
|
||||
# in the error hooks
|
||||
except Exception as err: # noqa
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
|
||||
error_message = getattr(err, "error_message", str(err))
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.GENERAL,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
finally:
|
||||
after_all_hooks(flag_type, hook_context, reversed_merged_hooks, hook_hints)
|
||||
|
||||
def _create_provider_evaluation(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: typing.Any,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagEvaluationDetails:
|
||||
"""
|
||||
Encapsulated method to create a FlagEvaluationDetail from a specific provider.
|
||||
|
||||
:param flag_type: the type of the flag being returned
|
||||
:param key: the string key of the selected flag
|
||||
:param default_value: backup value returned if no result found by the provider
|
||||
:param evaluation_context: Information for the purposes of flag evaluation
|
||||
:return: a FlagEvaluationDetails object with the fully evaluated flag from a
|
||||
provider
|
||||
"""
|
||||
args = (
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
)
|
||||
|
||||
if not self.provider:
|
||||
logging.info("No provider configured, using no-op provider.")
|
||||
self.provider = NoOpProvider()
|
||||
|
||||
get_details_callables: typing.Mapping[FlagType, GetDetailCallable] = {
|
||||
FlagType.BOOLEAN: self.provider.resolve_boolean_details,
|
||||
FlagType.INTEGER: self.provider.resolve_integer_details,
|
||||
FlagType.FLOAT: self.provider.resolve_float_details,
|
||||
FlagType.OBJECT: self.provider.resolve_object_details,
|
||||
FlagType.STRING: self.provider.resolve_string_details,
|
||||
}
|
||||
|
||||
get_details_callable = get_details_callables.get(flag_type)
|
||||
if not get_details_callable:
|
||||
raise GeneralError(error_message="Unknown flag type")
|
||||
|
||||
resolution = get_details_callable(*args)
|
||||
|
||||
# we need to check the get_args to be compatible with union types.
|
||||
_typecheck_flag_value(resolution.value, flag_type)
|
||||
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=resolution.value,
|
||||
variant=resolution.variant,
|
||||
reason=resolution.reason,
|
||||
error_code=resolution.error_code,
|
||||
error_message=resolution.error_message,
|
||||
)
|
||||
|
||||
|
||||
def _typecheck_flag_value(value, flag_type):
|
||||
type_map = {
|
||||
FlagType.BOOLEAN: bool,
|
||||
FlagType.STRING: str,
|
||||
FlagType.OBJECT: (dict, list),
|
||||
FlagType.FLOAT: float,
|
||||
FlagType.INTEGER: int,
|
||||
}
|
||||
_type = type_map.get(flag_type)
|
||||
if not _type:
|
||||
raise GeneralError(error_message="Unknown flag type")
|
||||
if not isinstance(value, _type):
|
||||
raise TypeMismatchError(f"Expected type {_type} but got {type(value)}")
|
|
@ -1,20 +0,0 @@
|
|||
import typing
|
||||
|
||||
|
||||
class EvaluationContext:
|
||||
def __init__(
|
||||
self,
|
||||
targeting_key: typing.Optional[str] = None,
|
||||
attributes: typing.Optional[dict] = None,
|
||||
):
|
||||
self.targeting_key = targeting_key
|
||||
self.attributes = attributes or {}
|
||||
|
||||
def merge(self, ctx2: "EvaluationContext") -> "EvaluationContext":
|
||||
if not (self and ctx2):
|
||||
return self or ctx2
|
||||
|
||||
attributes = {**self.attributes, **ctx2.attributes}
|
||||
targeting_key = ctx2.targeting_key or self.targeting_key
|
||||
|
||||
return EvaluationContext(targeting_key=targeting_key, attributes=attributes)
|
|
@ -1,60 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import typing
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from open_feature._backports.strenum import StrEnum
|
||||
from open_feature.exception import ErrorCode
|
||||
|
||||
if typing.TYPE_CHECKING: # resolves a circular dependency in type annotations
|
||||
from open_feature.hook import Hook
|
||||
|
||||
|
||||
class FlagType(StrEnum):
|
||||
BOOLEAN = "BOOLEAN"
|
||||
STRING = "STRING"
|
||||
OBJECT = "OBJECT"
|
||||
FLOAT = "FLOAT"
|
||||
INTEGER = "INTEGER"
|
||||
|
||||
|
||||
class Reason(StrEnum):
|
||||
CACHED = "CACHED"
|
||||
DEFAULT = "DEFAULT"
|
||||
DISABLED = "DISABLED"
|
||||
ERROR = "ERROR"
|
||||
STATIC = "STATIC"
|
||||
SPLIT = "SPLIT"
|
||||
TARGETING_MATCH = "TARGETING_MATCH"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
T = typing.TypeVar("T", covariant=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagEvaluationDetails(typing.Generic[T]):
|
||||
flag_key: str
|
||||
value: T
|
||||
variant: typing.Optional[str] = None
|
||||
reason: typing.Optional[Reason] = None
|
||||
error_code: typing.Optional[ErrorCode] = None
|
||||
error_message: typing.Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagEvaluationOptions:
|
||||
hooks: typing.List[Hook] = field(default_factory=list)
|
||||
hook_hints: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
U = typing.TypeVar("U", covariant=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagResolutionDetails(typing.Generic[U]):
|
||||
value: U
|
||||
error_code: typing.Optional[ErrorCode] = None
|
||||
error_message: typing.Optional[str] = None
|
||||
reason: typing.Optional[Reason] = None
|
||||
variant: typing.Optional[str] = None
|
||||
flag_metadata: typing.Optional[str] = None
|
|
@ -1,87 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
|
||||
|
||||
class HookType(Enum):
|
||||
BEFORE = "before"
|
||||
AFTER = "after"
|
||||
FINALLY_AFTER = "finally_after"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookContext:
|
||||
flag_key: str
|
||||
flag_type: FlagType
|
||||
default_value: typing.Any
|
||||
evaluation_context: EvaluationContext
|
||||
client_metadata: typing.Optional[dict] = None
|
||||
provider_metadata: typing.Optional[dict] = None
|
||||
|
||||
|
||||
class Hook:
|
||||
@abstractmethod
|
||||
def before(self, hook_context: HookContext, hints: dict) -> EvaluationContext:
|
||||
"""
|
||||
Runs before flag is resolved.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param hints: An immutable mapping of data for users to
|
||||
communicate to the hooks.
|
||||
:return: An EvaluationContext. It will be merged with the
|
||||
EvaluationContext instances from other hooks, the client and API.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def after(
|
||||
self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict
|
||||
):
|
||||
"""
|
||||
Runs after a flag is resolved.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param details: Information about how the flag was resolved,
|
||||
including any resolved values.
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def error(self, hook_context: HookContext, exception: Exception, hints: dict):
|
||||
"""
|
||||
Run when evaluation encounters an error. Errors thrown will be swallowed.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param exception: The exception that was thrown
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def finally_after(self, hook_context: HookContext, hints: dict):
|
||||
"""
|
||||
Run after flag evaluation, including any error processing.
|
||||
This will always run. Errors will be swallowed.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def supports_flag_value_type(self, flag_type: FlagType) -> bool:
|
||||
"""
|
||||
Check to see if the hook supports the particular flag type.
|
||||
|
||||
:param flag_type: particular type of the flag
|
||||
:return: a boolean containing whether the flag type is supported (True)
|
||||
or not (False)
|
||||
"""
|
||||
return True
|
|
@ -1,130 +0,0 @@
|
|||
import logging
|
||||
import typing
|
||||
from functools import reduce
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
from open_feature.hook import Hook, HookContext, HookType
|
||||
|
||||
|
||||
def error_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
exception: Exception,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints}
|
||||
_execute_hooks(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.ERROR, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def after_all_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
kwargs = {"hook_context": hook_context, "hints": hints}
|
||||
_execute_hooks(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.FINALLY_AFTER, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def after_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
details: FlagEvaluationDetails,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
|
||||
_execute_hooks_unchecked(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.AFTER, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def before_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
) -> EvaluationContext:
|
||||
kwargs = {"hook_context": hook_context, "hints": hints}
|
||||
executed_hooks = _execute_hooks_unchecked(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.BEFORE, **kwargs
|
||||
)
|
||||
filtered_hooks = list(filter(lambda hook: hook is not None, executed_hooks))
|
||||
|
||||
if filtered_hooks:
|
||||
return reduce(lambda a, b: a.merge(b), filtered_hooks)
|
||||
|
||||
return EvaluationContext()
|
||||
|
||||
|
||||
def _execute_hooks(
|
||||
flag_type: FlagType, hooks: typing.List[Hook], hook_method: HookType, **kwargs
|
||||
) -> list:
|
||||
"""
|
||||
Run multiple hooks of any hook type. All of these hooks will be run through an
|
||||
exception check.
|
||||
|
||||
:param flag_type: particular type of flag
|
||||
:param hooks: a list of hooks
|
||||
:param hook_method: the type of hook that is being run
|
||||
:param kwargs: arguments that need to be provided to the hook method
|
||||
:return: a list of results from the applied hook methods
|
||||
"""
|
||||
if hooks:
|
||||
filtered_hooks = list(
|
||||
filter(
|
||||
lambda hook: hook.supports_flag_value_type(flag_type=flag_type), hooks
|
||||
)
|
||||
)
|
||||
return [
|
||||
_execute_hook_checked(hook, hook_method, **kwargs)
|
||||
for hook in filtered_hooks
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _execute_hooks_unchecked(
|
||||
flag_type: FlagType, hooks, hook_method: HookType, **kwargs
|
||||
) -> list:
|
||||
"""
|
||||
Execute a single hook without checking whether an exception is thrown. This is
|
||||
used in the before and after hooks since any exception will be caught in the
|
||||
client.
|
||||
|
||||
:param flag_type: particular type of flag
|
||||
:param hooks: a list of hooks
|
||||
:param hook_method: the type of hook that is being run
|
||||
:param kwargs: arguments that need to be provided to the hook method
|
||||
:return: a list of results from the applied hook methods
|
||||
"""
|
||||
if hooks:
|
||||
filtered_hooks = list(
|
||||
filter(
|
||||
lambda hook: hook.supports_flag_value_type(flag_type=flag_type), hooks
|
||||
)
|
||||
)
|
||||
return [getattr(hook, hook_method.value)(**kwargs) for hook in filtered_hooks]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _execute_hook_checked(hook: Hook, hook_method: HookType, **kwargs):
|
||||
"""
|
||||
Try and run a single hook and catch any exception thrown. This is used in the
|
||||
after all and error hooks since any error thrown at this point needs to be caught.
|
||||
|
||||
:param hook: a list of hooks
|
||||
:param hook_method: the type of hook that is being run
|
||||
:param kwargs: arguments that need to be provided to the hook method
|
||||
:return: the result of the hook method
|
||||
"""
|
||||
try:
|
||||
return getattr(hook, hook_method.value)(**kwargs)
|
||||
except Exception: # noqa
|
||||
logging.error(f"Exception when running {hook_method.value} hooks")
|
|
@ -1,123 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from open_feature._backports.strenum import StrEnum
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.exception import ErrorCode
|
||||
from open_feature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.metadata import Metadata
|
||||
from open_feature.provider.provider import AbstractProvider
|
||||
|
||||
PASSED_IN_DEFAULT = "Passed in default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InMemoryMetadata(Metadata):
|
||||
name: str = "In-Memory Provider"
|
||||
|
||||
|
||||
T = typing.TypeVar("T", covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InMemoryFlag(typing.Generic[T]):
|
||||
class State(StrEnum):
|
||||
ENABLED = "ENABLED"
|
||||
DISABLED = "DISABLED"
|
||||
|
||||
flag_key: str
|
||||
default_variant: str
|
||||
variants: typing.Dict[str, T]
|
||||
state: State = State.ENABLED
|
||||
context_evaluator: typing.Optional[
|
||||
typing.Callable[["InMemoryFlag", EvaluationContext], FlagResolutionDetails[T]]
|
||||
] = None
|
||||
|
||||
def resolve(
|
||||
self, evaluation_context: typing.Optional[EvaluationContext]
|
||||
) -> FlagResolutionDetails[T]:
|
||||
if self.context_evaluator:
|
||||
return self.context_evaluator(
|
||||
self, evaluation_context or EvaluationContext()
|
||||
)
|
||||
|
||||
return FlagResolutionDetails(
|
||||
value=self.variants[self.default_variant],
|
||||
reason=Reason.STATIC,
|
||||
variant=self.default_variant,
|
||||
)
|
||||
|
||||
|
||||
FlagStorage = typing.Dict[str, InMemoryFlag]
|
||||
|
||||
V = typing.TypeVar("V")
|
||||
|
||||
|
||||
class InMemoryProvider(AbstractProvider):
|
||||
_flags: FlagStorage
|
||||
|
||||
def __init__(self, flags: FlagStorage):
|
||||
self._flags = flags.copy()
|
||||
|
||||
def get_metadata(self) -> Metadata:
|
||||
return InMemoryMetadata()
|
||||
|
||||
def get_provider_hooks(self) -> typing.List[Hook]:
|
||||
return []
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[dict, list],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[typing.Union[dict, list]]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
def _resolve(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: V,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> FlagResolutionDetails[V]:
|
||||
flag = self._flags.get(flag_key)
|
||||
if flag is None:
|
||||
return FlagResolutionDetails(
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.FLAG_NOT_FOUND,
|
||||
error_message=f"Flag '{flag_key}' not found",
|
||||
)
|
||||
return flag.resolve(evaluation_context)
|
|
@ -1,62 +0,0 @@
|
|||
import typing
|
||||
from abc import abstractmethod
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagResolutionDetails
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.metadata import Metadata
|
||||
|
||||
|
||||
class AbstractProvider:
|
||||
@abstractmethod
|
||||
def get_metadata(self) -> Metadata:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_hooks(self) -> typing.List[Hook]:
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[dict, list],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[typing.Union[dict, list]]:
|
||||
pass
|
|
@ -0,0 +1,14 @@
|
|||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
# re-export needed for type checking
|
||||
from enum import StrEnum as StrEnum # noqa: PLC0414
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""
|
||||
Backport StrEnum for Python <3.11
|
||||
"""
|
||||
|
||||
pass
|
|
@ -0,0 +1,108 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openfeature.event import (
|
||||
EventDetails,
|
||||
EventHandler,
|
||||
ProviderEvent,
|
||||
ProviderEventDetails,
|
||||
)
|
||||
from openfeature.provider import FeatureProvider, ProviderStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openfeature.client import OpenFeatureClient
|
||||
|
||||
|
||||
_global_lock = threading.RLock()
|
||||
_global_handlers: dict[ProviderEvent, list[EventHandler]] = defaultdict(list)
|
||||
|
||||
_client_lock = threading.RLock()
|
||||
_client_handlers: dict[OpenFeatureClient, dict[ProviderEvent, list[EventHandler]]] = (
|
||||
defaultdict(lambda: defaultdict(list))
|
||||
)
|
||||
|
||||
|
||||
def run_client_handlers(
|
||||
client: OpenFeatureClient, event: ProviderEvent, details: EventDetails
|
||||
) -> None:
|
||||
with _client_lock:
|
||||
for handler in _client_handlers[client][event]:
|
||||
handler(details)
|
||||
|
||||
|
||||
def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None:
|
||||
with _global_lock:
|
||||
for handler in _global_handlers[event]:
|
||||
handler(details)
|
||||
|
||||
|
||||
def add_client_handler(
|
||||
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
|
||||
) -> None:
|
||||
with _client_lock:
|
||||
handlers = _client_handlers[client][event]
|
||||
handlers.append(handler)
|
||||
|
||||
_run_immediate_handler(client, event, handler)
|
||||
|
||||
|
||||
def remove_client_handler(
|
||||
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
|
||||
) -> None:
|
||||
with _client_lock:
|
||||
handlers = _client_handlers[client][event]
|
||||
handlers.remove(handler)
|
||||
|
||||
|
||||
def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
|
||||
with _global_lock:
|
||||
_global_handlers[event].append(handler)
|
||||
|
||||
from openfeature.api import get_client # noqa: PLC0415
|
||||
|
||||
_run_immediate_handler(get_client(), event, handler)
|
||||
|
||||
|
||||
def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
|
||||
with _global_lock:
|
||||
_global_handlers[event].remove(handler)
|
||||
|
||||
|
||||
def run_handlers_for_provider(
|
||||
provider: FeatureProvider,
|
||||
event: ProviderEvent,
|
||||
provider_details: ProviderEventDetails,
|
||||
) -> None:
|
||||
details = EventDetails.from_provider_event_details(
|
||||
provider.get_metadata().name, provider_details
|
||||
)
|
||||
# run the global handlers
|
||||
run_global_handlers(event, details)
|
||||
# run the handlers for clients associated to this provider
|
||||
with _client_lock:
|
||||
for client in _client_handlers:
|
||||
if client.provider == provider:
|
||||
run_client_handlers(client, event, details)
|
||||
|
||||
|
||||
def _run_immediate_handler(
|
||||
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
|
||||
) -> None:
|
||||
status_to_event = {
|
||||
ProviderStatus.READY: ProviderEvent.PROVIDER_READY,
|
||||
ProviderStatus.ERROR: ProviderEvent.PROVIDER_ERROR,
|
||||
ProviderStatus.FATAL: ProviderEvent.PROVIDER_ERROR,
|
||||
ProviderStatus.STALE: ProviderEvent.PROVIDER_STALE,
|
||||
}
|
||||
if event == status_to_event.get(client.get_provider_status()):
|
||||
handler(EventDetails(provider_name=client.provider.get_metadata().name))
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
with _global_lock:
|
||||
_global_handlers.clear()
|
||||
with _client_lock:
|
||||
_client_handlers.clear()
|
|
@ -0,0 +1,75 @@
|
|||
import typing
|
||||
|
||||
from openfeature import _event_support
|
||||
from openfeature.client import OpenFeatureClient
|
||||
from openfeature.evaluation_context import (
|
||||
get_evaluation_context,
|
||||
set_evaluation_context,
|
||||
)
|
||||
from openfeature.event import (
|
||||
EventHandler,
|
||||
ProviderEvent,
|
||||
)
|
||||
from openfeature.hook import add_hooks, clear_hooks, get_hooks
|
||||
from openfeature.provider import FeatureProvider
|
||||
from openfeature.provider._registry import provider_registry
|
||||
from openfeature.provider.metadata import Metadata
|
||||
from openfeature.transaction_context import (
|
||||
get_transaction_context,
|
||||
set_transaction_context,
|
||||
set_transaction_context_propagator,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"add_handler",
|
||||
"add_hooks",
|
||||
"clear_hooks",
|
||||
"clear_providers",
|
||||
"get_client",
|
||||
"get_evaluation_context",
|
||||
"get_hooks",
|
||||
"get_provider_metadata",
|
||||
"get_transaction_context",
|
||||
"remove_handler",
|
||||
"set_evaluation_context",
|
||||
"set_provider",
|
||||
"set_transaction_context",
|
||||
"set_transaction_context_propagator",
|
||||
"shutdown",
|
||||
]
|
||||
|
||||
|
||||
def get_client(
|
||||
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
|
||||
) -> OpenFeatureClient:
|
||||
return OpenFeatureClient(domain=domain, version=version)
|
||||
|
||||
|
||||
def set_provider(
|
||||
provider: FeatureProvider, domain: typing.Optional[str] = None
|
||||
) -> None:
|
||||
if domain is None:
|
||||
provider_registry.set_default_provider(provider)
|
||||
else:
|
||||
provider_registry.set_provider(domain, provider)
|
||||
|
||||
|
||||
def clear_providers() -> None:
|
||||
provider_registry.clear_providers()
|
||||
_event_support.clear()
|
||||
|
||||
|
||||
def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata:
|
||||
return provider_registry.get_provider(domain).get_metadata()
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
provider_registry.shutdown()
|
||||
|
||||
|
||||
def add_handler(event: ProviderEvent, handler: EventHandler) -> None:
|
||||
_event_support.add_global_handler(event, handler)
|
||||
|
||||
|
||||
def remove_handler(event: ProviderEvent, handler: EventHandler) -> None:
|
||||
_event_support.remove_global_handler(event, handler)
|
|
@ -0,0 +1,978 @@
|
|||
import logging
|
||||
import typing
|
||||
from collections.abc import Awaitable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openfeature import _event_support
|
||||
from openfeature.evaluation_context import EvaluationContext, get_evaluation_context
|
||||
from openfeature.event import EventHandler, ProviderEvent
|
||||
from openfeature.exception import (
|
||||
ErrorCode,
|
||||
GeneralError,
|
||||
OpenFeatureError,
|
||||
ProviderFatalError,
|
||||
ProviderNotReadyError,
|
||||
TypeMismatchError,
|
||||
)
|
||||
from openfeature.flag_evaluation import (
|
||||
FlagEvaluationDetails,
|
||||
FlagEvaluationOptions,
|
||||
FlagResolutionDetails,
|
||||
FlagType,
|
||||
FlagValueType,
|
||||
Reason,
|
||||
)
|
||||
from openfeature.hook import Hook, HookContext, HookHints, get_hooks
|
||||
from openfeature.hook._hook_support import (
|
||||
after_all_hooks,
|
||||
after_hooks,
|
||||
before_hooks,
|
||||
error_hooks,
|
||||
)
|
||||
from openfeature.provider import FeatureProvider, ProviderStatus
|
||||
from openfeature.provider._registry import provider_registry
|
||||
from openfeature.transaction_context import get_transaction_context
|
||||
|
||||
__all__ = [
|
||||
"ClientMetadata",
|
||||
"OpenFeatureClient",
|
||||
]
|
||||
|
||||
logger = logging.getLogger("openfeature")
|
||||
|
||||
TypeMap = dict[
|
||||
FlagType,
|
||||
typing.Union[
|
||||
type[bool],
|
||||
type[int],
|
||||
type[float],
|
||||
type[str],
|
||||
tuple[type[dict], type[list]],
|
||||
],
|
||||
]
|
||||
|
||||
T = typing.TypeVar("T", bool, int, float, str, typing.Union[dict, list])
|
||||
|
||||
|
||||
class ResolveDetailsCallable(typing.Protocol[T]):
|
||||
def __call__(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: T,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> FlagResolutionDetails[T]: ...
|
||||
|
||||
|
||||
class ResolveDetailsCallableAsync(typing.Protocol[T]):
|
||||
def __call__(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: T,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> Awaitable[FlagResolutionDetails[T]]: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientMetadata:
|
||||
name: typing.Optional[str] = None
|
||||
domain: typing.Optional[str] = None
|
||||
|
||||
|
||||
class OpenFeatureClient:
|
||||
def __init__(
|
||||
self,
|
||||
domain: typing.Optional[str],
|
||||
version: typing.Optional[str],
|
||||
context: typing.Optional[EvaluationContext] = None,
|
||||
hooks: typing.Optional[list[Hook]] = None,
|
||||
) -> None:
|
||||
self.domain = domain
|
||||
self.version = version
|
||||
self.context = context or EvaluationContext()
|
||||
self.hooks = hooks or []
|
||||
|
||||
@property
|
||||
def provider(self) -> FeatureProvider:
|
||||
return provider_registry.get_provider(self.domain)
|
||||
|
||||
def get_provider_status(self) -> ProviderStatus:
|
||||
return provider_registry.get_provider_status(self.provider)
|
||||
|
||||
def get_metadata(self) -> ClientMetadata:
|
||||
return ClientMetadata(domain=self.domain)
|
||||
|
||||
def add_hooks(self, hooks: list[Hook]) -> None:
|
||||
self.hooks = self.hooks + hooks
|
||||
|
||||
def get_boolean_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> bool:
|
||||
return self.get_boolean_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
async def get_boolean_value_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> bool:
|
||||
details = await self.get_boolean_details_async(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
return details.value
|
||||
|
||||
def get_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[bool]:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.BOOLEAN,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
async def get_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[bool]:
|
||||
return await self.evaluate_flag_details_async(
|
||||
FlagType.BOOLEAN,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_string_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> str:
|
||||
return self.get_string_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
async def get_string_value_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> str:
|
||||
details = await self.get_string_details_async(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
return details.value
|
||||
|
||||
def get_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[str]:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.STRING,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
async def get_string_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[str]:
|
||||
return await self.evaluate_flag_details_async(
|
||||
FlagType.STRING,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_integer_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> int:
|
||||
return self.get_integer_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
async def get_integer_value_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> int:
|
||||
details = await self.get_integer_details_async(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
return details.value
|
||||
|
||||
def get_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[int]:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.INTEGER,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
async def get_integer_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[int]:
|
||||
return await self.evaluate_flag_details_async(
|
||||
FlagType.INTEGER,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_float_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> float:
|
||||
return self.get_float_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
async def get_float_value_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> float:
|
||||
details = await self.get_float_details_async(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
return details.value
|
||||
|
||||
def get_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[float]:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.FLOAT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
async def get_float_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[float]:
|
||||
return await self.evaluate_flag_details_async(
|
||||
FlagType.FLOAT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def get_object_value(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]:
|
||||
return self.get_object_details(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
).value
|
||||
|
||||
async def get_object_value_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]:
|
||||
details = await self.get_object_details_async(
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
return details.value
|
||||
|
||||
def get_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return self.evaluate_flag_details(
|
||||
FlagType.OBJECT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
async def get_object_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return await self.evaluate_flag_details_async(
|
||||
FlagType.OBJECT,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
|
||||
def _establish_hooks_and_provider(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions],
|
||||
) -> tuple[
|
||||
FeatureProvider,
|
||||
HookContext,
|
||||
HookHints,
|
||||
list[Hook],
|
||||
list[Hook],
|
||||
]:
|
||||
if evaluation_context is None:
|
||||
evaluation_context = EvaluationContext()
|
||||
|
||||
if flag_evaluation_options is None:
|
||||
flag_evaluation_options = FlagEvaluationOptions()
|
||||
|
||||
provider = self.provider # call this once to maintain a consistent reference
|
||||
evaluation_hooks = flag_evaluation_options.hooks
|
||||
hook_hints = flag_evaluation_options.hook_hints
|
||||
|
||||
# Merge transaction context into evaluation context before creating hook_context
|
||||
# This ensures hooks have access to the complete context including transaction context
|
||||
merged_eval_context = (
|
||||
get_evaluation_context()
|
||||
.merge(get_transaction_context())
|
||||
.merge(self.context)
|
||||
.merge(evaluation_context)
|
||||
)
|
||||
|
||||
hook_context = HookContext(
|
||||
flag_key=flag_key,
|
||||
flag_type=flag_type,
|
||||
default_value=default_value,
|
||||
evaluation_context=merged_eval_context,
|
||||
client_metadata=self.get_metadata(),
|
||||
provider_metadata=provider.get_metadata(),
|
||||
)
|
||||
# Hooks need to be handled in different orders at different stages
|
||||
# in the flag evaluation
|
||||
# before: API, Client, Invocation, Provider
|
||||
merged_hooks = (
|
||||
get_hooks() + self.hooks + evaluation_hooks + provider.get_provider_hooks()
|
||||
)
|
||||
# after, error, finally: Provider, Invocation, Client, API
|
||||
reversed_merged_hooks = merged_hooks[:]
|
||||
reversed_merged_hooks.reverse()
|
||||
|
||||
return provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks
|
||||
|
||||
def _assert_provider_status(
|
||||
self,
|
||||
) -> typing.Optional[OpenFeatureError]:
|
||||
status = self.get_provider_status()
|
||||
if status == ProviderStatus.NOT_READY:
|
||||
return ProviderNotReadyError()
|
||||
if status == ProviderStatus.FATAL:
|
||||
return ProviderFatalError()
|
||||
return None
|
||||
|
||||
def _run_before_hooks_and_update_context(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
merged_hooks: list[Hook],
|
||||
hook_hints: HookHints,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> EvaluationContext:
|
||||
# https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md
|
||||
# Any resulting evaluation context from a before hook will overwrite
|
||||
# duplicate fields defined globally, on the client, or in the invocation.
|
||||
# Requirement 3.2.2, 4.3.4: API.context->client.context->invocation.context
|
||||
before_hooks_context = before_hooks(
|
||||
flag_type, hook_context, merged_hooks, hook_hints
|
||||
)
|
||||
|
||||
# The hook_context.evaluation_context already contains the merged context from
|
||||
# _establish_hooks_and_provider, so we just need to merge with the before hooks result
|
||||
merged_context = hook_context.evaluation_context.merge(before_hooks_context)
|
||||
|
||||
return merged_context
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[bool]: ...
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[int]: ...
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[float]: ...
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[str]: ...
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: Sequence["FlagValueType"],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ...
|
||||
|
||||
@typing.overload
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: typing.Mapping[str, "FlagValueType"],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[typing.Mapping[str, "FlagValueType"]]: ...
|
||||
|
||||
async def evaluate_flag_details_async(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[FlagValueType]:
|
||||
"""
|
||||
Evaluate the flag requested by the user from the clients provider.
|
||||
|
||||
:param flag_type: the type of the flag being returned
|
||||
:param flag_key: the string key of the selected flag
|
||||
:param default_value: backup value returned if no result found by the provider
|
||||
:param evaluation_context: Information for the purposes of flag evaluation
|
||||
:param flag_evaluation_options: Additional flag evaluation information
|
||||
:return: a typing.Awaitable[FlagEvaluationDetails] object with the fully evaluated flag from a
|
||||
provider
|
||||
"""
|
||||
provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks = (
|
||||
self._establish_hooks_and_provider(
|
||||
flag_type,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if provider_err := self._assert_provider_status():
|
||||
error_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
provider_err,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=provider_err.error_code,
|
||||
error_message=provider_err.error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
|
||||
merged_context = self._run_before_hooks_and_update_context(
|
||||
flag_type,
|
||||
hook_context,
|
||||
merged_hooks,
|
||||
hook_hints,
|
||||
evaluation_context,
|
||||
)
|
||||
|
||||
flag_evaluation = await self._create_provider_evaluation_async(
|
||||
provider,
|
||||
flag_type,
|
||||
flag_key,
|
||||
default_value,
|
||||
merged_context,
|
||||
)
|
||||
if err := flag_evaluation.get_exception():
|
||||
error_hooks(
|
||||
flag_type, hook_context, err, reversed_merged_hooks, hook_hints
|
||||
)
|
||||
return flag_evaluation
|
||||
|
||||
after_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
flag_evaluation,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
|
||||
return flag_evaluation
|
||||
|
||||
except OpenFeatureError as err:
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=err.error_code,
|
||||
error_message=err.error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
# Catch any type of exception here since the user can provide any exception
|
||||
# in the error hooks
|
||||
except Exception as err: # pragma: no cover
|
||||
logger.exception(
|
||||
"Unable to correctly evaluate flag with key: '%s'", flag_key
|
||||
)
|
||||
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
|
||||
error_message = getattr(err, "error_message", str(err))
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.GENERAL,
|
||||
error_message=error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
|
||||
finally:
|
||||
after_all_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
flag_evaluation,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[bool]: ...
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[int]: ...
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[float]: ...
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[str]: ...
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: Sequence["FlagValueType"],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ...
|
||||
|
||||
@typing.overload
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: typing.Mapping[str, "FlagValueType"],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[typing.Mapping[str, "FlagValueType"]]: ...
|
||||
|
||||
def evaluate_flag_details(
|
||||
self,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None,
|
||||
) -> FlagEvaluationDetails[FlagValueType]:
|
||||
"""
|
||||
Evaluate the flag requested by the user from the clients provider.
|
||||
|
||||
:param flag_type: the type of the flag being returned
|
||||
:param flag_key: the string key of the selected flag
|
||||
:param default_value: backup value returned if no result found by the provider
|
||||
:param evaluation_context: Information for the purposes of flag evaluation
|
||||
:param flag_evaluation_options: Additional flag evaluation information
|
||||
:return: a FlagEvaluationDetails object with the fully evaluated flag from a
|
||||
provider
|
||||
"""
|
||||
provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks = (
|
||||
self._establish_hooks_and_provider(
|
||||
flag_type,
|
||||
flag_key,
|
||||
default_value,
|
||||
evaluation_context,
|
||||
flag_evaluation_options,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if provider_err := self._assert_provider_status():
|
||||
error_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
provider_err,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=provider_err.error_code,
|
||||
error_message=provider_err.error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
|
||||
merged_context = self._run_before_hooks_and_update_context(
|
||||
flag_type,
|
||||
hook_context,
|
||||
merged_hooks,
|
||||
hook_hints,
|
||||
evaluation_context,
|
||||
)
|
||||
|
||||
flag_evaluation = self._create_provider_evaluation(
|
||||
provider,
|
||||
flag_type,
|
||||
flag_key,
|
||||
default_value,
|
||||
merged_context,
|
||||
)
|
||||
if err := flag_evaluation.get_exception():
|
||||
error_hooks(
|
||||
flag_type, hook_context, err, reversed_merged_hooks, hook_hints
|
||||
)
|
||||
flag_evaluation.value = default_value
|
||||
return flag_evaluation
|
||||
|
||||
after_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
flag_evaluation,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
|
||||
return flag_evaluation
|
||||
|
||||
except OpenFeatureError as err:
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=err.error_code,
|
||||
error_message=err.error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
# Catch any type of exception here since the user can provide any exception
|
||||
# in the error hooks
|
||||
except Exception as err: # pragma: no cover
|
||||
logger.exception(
|
||||
"Unable to correctly evaluate flag with key: '%s'", flag_key
|
||||
)
|
||||
|
||||
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
|
||||
|
||||
error_message = getattr(err, "error_message", str(err))
|
||||
flag_evaluation = FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.GENERAL,
|
||||
error_message=error_message,
|
||||
)
|
||||
return flag_evaluation
|
||||
|
||||
finally:
|
||||
after_all_hooks(
|
||||
flag_type,
|
||||
hook_context,
|
||||
flag_evaluation,
|
||||
reversed_merged_hooks,
|
||||
hook_hints,
|
||||
)
|
||||
|
||||
async def _create_provider_evaluation_async(
|
||||
self,
|
||||
provider: FeatureProvider,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagEvaluationDetails[FlagValueType]:
|
||||
get_details_callables_async: typing.Mapping[
|
||||
FlagType, ResolveDetailsCallableAsync
|
||||
] = {
|
||||
FlagType.BOOLEAN: provider.resolve_boolean_details_async,
|
||||
FlagType.INTEGER: provider.resolve_integer_details_async,
|
||||
FlagType.FLOAT: provider.resolve_float_details_async,
|
||||
FlagType.OBJECT: provider.resolve_object_details_async,
|
||||
FlagType.STRING: provider.resolve_string_details_async,
|
||||
}
|
||||
get_details_callable = get_details_callables_async.get(flag_type)
|
||||
if not get_details_callable:
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.GENERAL,
|
||||
error_message="Unknown flag type",
|
||||
)
|
||||
|
||||
resolution = await get_details_callable(
|
||||
flag_key=flag_key,
|
||||
default_value=default_value,
|
||||
evaluation_context=evaluation_context,
|
||||
)
|
||||
if resolution.error_code:
|
||||
return resolution.to_flag_evaluation_details(flag_key)
|
||||
|
||||
# we need to check the get_args to be compatible with union types.
|
||||
if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type):
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=resolution.value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=err.error_code,
|
||||
error_message=err.error_message,
|
||||
)
|
||||
|
||||
return resolution.to_flag_evaluation_details(flag_key)
|
||||
|
||||
def _create_provider_evaluation(
|
||||
self,
|
||||
provider: FeatureProvider,
|
||||
flag_type: FlagType,
|
||||
flag_key: str,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagEvaluationDetails[FlagValueType]:
|
||||
"""
|
||||
Encapsulated method to create a FlagEvaluationDetail from a specific provider.
|
||||
|
||||
:param flag_type: the type of the flag being returned
|
||||
:param key: the string key of the selected flag
|
||||
:param default_value: backup value returned if no result found by the provider
|
||||
:param evaluation_context: Information for the purposes of flag evaluation
|
||||
:return: a FlagEvaluationDetails object with the fully evaluated flag from a
|
||||
provider
|
||||
"""
|
||||
get_details_callables: typing.Mapping[FlagType, ResolveDetailsCallable] = {
|
||||
FlagType.BOOLEAN: provider.resolve_boolean_details,
|
||||
FlagType.INTEGER: provider.resolve_integer_details,
|
||||
FlagType.FLOAT: provider.resolve_float_details,
|
||||
FlagType.OBJECT: provider.resolve_object_details,
|
||||
FlagType.STRING: provider.resolve_string_details,
|
||||
}
|
||||
|
||||
get_details_callable = get_details_callables.get(flag_type)
|
||||
if not get_details_callable:
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.GENERAL,
|
||||
error_message="Unknown flag type",
|
||||
)
|
||||
|
||||
resolution = get_details_callable(
|
||||
flag_key=flag_key,
|
||||
default_value=default_value,
|
||||
evaluation_context=evaluation_context,
|
||||
)
|
||||
if resolution.error_code:
|
||||
return resolution.to_flag_evaluation_details(flag_key)
|
||||
|
||||
# we need to check the get_args to be compatible with union types.
|
||||
if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type):
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=resolution.value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=err.error_code,
|
||||
error_message=err.error_message,
|
||||
)
|
||||
|
||||
return resolution.to_flag_evaluation_details(flag_key)
|
||||
|
||||
def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
|
||||
_event_support.add_client_handler(self, event, handler)
|
||||
|
||||
def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
|
||||
_event_support.remove_client_handler(self, event, handler)
|
||||
|
||||
|
||||
def _typecheck_flag_value(
|
||||
value: typing.Any, flag_type: FlagType
|
||||
) -> typing.Optional[OpenFeatureError]:
|
||||
type_map: TypeMap = {
|
||||
FlagType.BOOLEAN: bool,
|
||||
FlagType.STRING: str,
|
||||
FlagType.OBJECT: (dict, list),
|
||||
FlagType.FLOAT: float,
|
||||
FlagType.INTEGER: int,
|
||||
}
|
||||
_type = type_map.get(flag_type)
|
||||
if not _type:
|
||||
return GeneralError(error_message="Unknown flag type")
|
||||
if not isinstance(value, _type):
|
||||
return TypeMismatchError(f"Expected type {_type} but got {type(value)}")
|
||||
return None
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from openfeature.exception import GeneralError
|
||||
|
||||
__all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"]
|
||||
|
||||
# https://openfeature.dev/specification/sections/evaluation-context#requirement-312
|
||||
EvaluationContextAttributes = typing.Mapping[
|
||||
str,
|
||||
typing.Union[
|
||||
bool,
|
||||
int,
|
||||
float,
|
||||
str,
|
||||
datetime,
|
||||
Sequence["EvaluationContextAttributes"],
|
||||
typing.Mapping[str, "EvaluationContextAttributes"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluationContext:
|
||||
targeting_key: typing.Optional[str] = None
|
||||
attributes: EvaluationContextAttributes = field(default_factory=dict)
|
||||
|
||||
def merge(self, ctx2: EvaluationContext) -> EvaluationContext:
|
||||
if not (self and ctx2):
|
||||
return self or ctx2
|
||||
|
||||
attributes = {**self.attributes, **ctx2.attributes}
|
||||
targeting_key = ctx2.targeting_key or self.targeting_key
|
||||
|
||||
return EvaluationContext(targeting_key=targeting_key, attributes=attributes)
|
||||
|
||||
|
||||
def get_evaluation_context() -> EvaluationContext:
|
||||
return _evaluation_context
|
||||
|
||||
|
||||
def set_evaluation_context(evaluation_context: EvaluationContext) -> None:
|
||||
global _evaluation_context
|
||||
if evaluation_context is None:
|
||||
raise GeneralError(error_message="No api level evaluation context")
|
||||
_evaluation_context = evaluation_context
|
||||
|
||||
|
||||
# need to be at the bottom, because of the definition order
|
||||
_evaluation_context = EvaluationContext()
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional, Union
|
||||
|
||||
from openfeature.exception import ErrorCode
|
||||
|
||||
__all__ = ["EventDetails", "EventHandler", "ProviderEvent", "ProviderEventDetails"]
|
||||
|
||||
|
||||
class ProviderEvent(Enum):
|
||||
PROVIDER_READY = "PROVIDER_READY"
|
||||
PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED"
|
||||
PROVIDER_ERROR = "PROVIDER_ERROR"
|
||||
PROVIDER_STALE = "PROVIDER_STALE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderEventDetails:
|
||||
flags_changed: Optional[list[str]] = None
|
||||
message: Optional[str] = None
|
||||
error_code: Optional[ErrorCode] = None
|
||||
metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventDetails(ProviderEventDetails):
|
||||
provider_name: str = ""
|
||||
flags_changed: Optional[list[str]] = None
|
||||
message: Optional[str] = None
|
||||
error_code: Optional[ErrorCode] = None
|
||||
metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_provider_event_details(
|
||||
cls, provider_name: str, details: ProviderEventDetails
|
||||
) -> EventDetails:
|
||||
return cls(
|
||||
provider_name=provider_name,
|
||||
flags_changed=details.flags_changed,
|
||||
message=details.message,
|
||||
error_code=details.error_code,
|
||||
metadata=details.metadata,
|
||||
)
|
||||
|
||||
|
||||
EventHandler = Callable[[EventDetails], None]
|
|
@ -1,15 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from enum import Enum
|
||||
from collections.abc import Mapping
|
||||
|
||||
from openfeature._backports.strenum import StrEnum
|
||||
|
||||
class ErrorCode(Enum):
|
||||
PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
|
||||
FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
|
||||
PARSE_ERROR = "PARSE_ERROR"
|
||||
TYPE_MISMATCH = "TYPE_MISMATCH"
|
||||
TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
|
||||
INVALID_CONTEXT = "INVALID_CONTEXT"
|
||||
GENERAL = "GENERAL"
|
||||
__all__ = [
|
||||
"ErrorCode",
|
||||
"FlagNotFoundError",
|
||||
"GeneralError",
|
||||
"InvalidContextError",
|
||||
"OpenFeatureError",
|
||||
"ParseError",
|
||||
"ProviderFatalError",
|
||||
"ProviderNotReadyError",
|
||||
"TargetingKeyMissingError",
|
||||
"TypeMismatchError",
|
||||
]
|
||||
|
||||
|
||||
class OpenFeatureError(Exception):
|
||||
|
@ -31,6 +38,36 @@ class OpenFeatureError(Exception):
|
|||
self.error_code = error_code
|
||||
|
||||
|
||||
class ProviderNotReadyError(OpenFeatureError):
|
||||
"""
|
||||
This exception should be raised when the provider is not ready to be used.
|
||||
"""
|
||||
|
||||
def __init__(self, error_message: typing.Optional[str] = None):
|
||||
"""
|
||||
Constructor for the ProviderNotReadyError. The error code for this type of
|
||||
exception is ErrorCode.PROVIDER_NOT_READY.
|
||||
@param error_message: a string message representing why the error has been
|
||||
raised
|
||||
"""
|
||||
super().__init__(ErrorCode.PROVIDER_NOT_READY, error_message)
|
||||
|
||||
|
||||
class ProviderFatalError(OpenFeatureError):
|
||||
"""
|
||||
This exception should be raised when the provider encounters a fatal error.
|
||||
"""
|
||||
|
||||
def __init__(self, error_message: typing.Optional[str] = None):
|
||||
"""
|
||||
Constructor for the ProviderFatalError. The error code for this type of
|
||||
exception is ErrorCode.PROVIDER_FATAL.
|
||||
@param error_message: a string message representing why the error has been
|
||||
raised
|
||||
"""
|
||||
super().__init__(ErrorCode.PROVIDER_FATAL, error_message)
|
||||
|
||||
|
||||
class FlagNotFoundError(OpenFeatureError):
|
||||
"""
|
||||
This exception should be raised when the provider cannot find a flag with the
|
||||
|
@ -125,3 +162,32 @@ class InvalidContextError(OpenFeatureError):
|
|||
raised
|
||||
"""
|
||||
super().__init__(ErrorCode.INVALID_CONTEXT, error_message)
|
||||
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
|
||||
PROVIDER_FATAL = "PROVIDER_FATAL"
|
||||
FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
|
||||
PARSE_ERROR = "PARSE_ERROR"
|
||||
TYPE_MISMATCH = "TYPE_MISMATCH"
|
||||
TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
|
||||
INVALID_CONTEXT = "INVALID_CONTEXT"
|
||||
GENERAL = "GENERAL"
|
||||
|
||||
__exceptions__: Mapping[str, typing.Callable[[str], OpenFeatureError]] = {
|
||||
PROVIDER_NOT_READY: ProviderNotReadyError,
|
||||
PROVIDER_FATAL: ProviderFatalError,
|
||||
FLAG_NOT_FOUND: FlagNotFoundError,
|
||||
PARSE_ERROR: ParseError,
|
||||
TYPE_MISMATCH: TypeMismatchError,
|
||||
TARGETING_KEY_MISSING: TargetingKeyMissingError,
|
||||
INVALID_CONTEXT: InvalidContextError,
|
||||
GENERAL: GeneralError,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def to_exception(
|
||||
cls, error_code: ErrorCode, error_message: str
|
||||
) -> OpenFeatureError:
|
||||
exc = cls.__exceptions__.get(error_code.value, GeneralError)
|
||||
return exc(error_message)
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from openfeature._backports.strenum import StrEnum
|
||||
from openfeature.exception import ErrorCode, OpenFeatureError
|
||||
|
||||
if typing.TYPE_CHECKING: # pragma: no cover
|
||||
# resolves a circular dependency in type annotations
|
||||
from openfeature.hook import Hook, HookHints
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FlagEvaluationDetails",
|
||||
"FlagEvaluationOptions",
|
||||
"FlagMetadata",
|
||||
"FlagResolutionDetails",
|
||||
"FlagType",
|
||||
"Reason",
|
||||
]
|
||||
|
||||
|
||||
class FlagType(StrEnum):
|
||||
BOOLEAN = "BOOLEAN"
|
||||
STRING = "STRING"
|
||||
OBJECT = "OBJECT"
|
||||
FLOAT = "FLOAT"
|
||||
INTEGER = "INTEGER"
|
||||
|
||||
|
||||
class Reason(StrEnum):
|
||||
CACHED = "CACHED"
|
||||
DEFAULT = "DEFAULT"
|
||||
DISABLED = "DISABLED"
|
||||
ERROR = "ERROR"
|
||||
SPLIT = "SPLIT"
|
||||
STATIC = "STATIC"
|
||||
STALE = "STALE"
|
||||
TARGETING_MATCH = "TARGETING_MATCH"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
FlagMetadata = typing.Mapping[str, typing.Union[bool, int, float, str]]
|
||||
FlagValueType = typing.Union[
|
||||
bool,
|
||||
int,
|
||||
float,
|
||||
str,
|
||||
Sequence["FlagValueType"],
|
||||
typing.Mapping[str, "FlagValueType"],
|
||||
]
|
||||
|
||||
T_co = typing.TypeVar("T_co", covariant=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagEvaluationDetails(typing.Generic[T_co]):
|
||||
flag_key: str
|
||||
value: T_co
|
||||
variant: typing.Optional[str] = None
|
||||
flag_metadata: FlagMetadata = field(default_factory=dict)
|
||||
reason: typing.Optional[typing.Union[str, Reason]] = None
|
||||
error_code: typing.Optional[ErrorCode] = None
|
||||
error_message: typing.Optional[str] = None
|
||||
|
||||
def get_exception(self) -> typing.Optional[OpenFeatureError]:
|
||||
if self.error_code:
|
||||
return ErrorCode.to_exception(self.error_code, self.error_message or "")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagEvaluationOptions:
|
||||
hooks: list[Hook] = field(default_factory=list)
|
||||
hook_hints: HookHints = field(default_factory=dict)
|
||||
|
||||
|
||||
U_co = typing.TypeVar("U_co", covariant=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlagResolutionDetails(typing.Generic[U_co]):
|
||||
value: U_co
|
||||
error_code: typing.Optional[ErrorCode] = None
|
||||
error_message: typing.Optional[str] = None
|
||||
reason: typing.Optional[typing.Union[str, Reason]] = None
|
||||
variant: typing.Optional[str] = None
|
||||
flag_metadata: FlagMetadata = field(default_factory=dict)
|
||||
|
||||
def raise_for_error(self) -> None:
|
||||
if self.error_code:
|
||||
raise ErrorCode.to_exception(self.error_code, self.error_message or "")
|
||||
return None
|
||||
|
||||
def to_flag_evaluation_details(self, flag_key: str) -> FlagEvaluationDetails[U_co]:
|
||||
return FlagEvaluationDetails(
|
||||
flag_key=flag_key,
|
||||
value=self.value,
|
||||
variant=self.variant,
|
||||
flag_metadata=self.flag_metadata,
|
||||
reason=self.reason,
|
||||
error_code=self.error_code,
|
||||
error_message=self.error_message,
|
||||
)
|
|
@ -0,0 +1,160 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openfeature.client import ClientMetadata
|
||||
from openfeature.provider.metadata import Metadata
|
||||
|
||||
__all__ = [
|
||||
"Hook",
|
||||
"HookContext",
|
||||
"HookHints",
|
||||
"HookType",
|
||||
"add_hooks",
|
||||
"clear_hooks",
|
||||
"get_hooks",
|
||||
]
|
||||
|
||||
_hooks: list[Hook] = []
|
||||
|
||||
|
||||
class HookType(Enum):
|
||||
BEFORE = "before"
|
||||
AFTER = "after"
|
||||
FINALLY_AFTER = "finally_after"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class HookContext:
|
||||
def __init__(
|
||||
self,
|
||||
flag_key: str,
|
||||
flag_type: FlagType,
|
||||
default_value: FlagValueType,
|
||||
evaluation_context: EvaluationContext,
|
||||
client_metadata: typing.Optional[ClientMetadata] = None,
|
||||
provider_metadata: typing.Optional[Metadata] = None,
|
||||
):
|
||||
self.flag_key = flag_key
|
||||
self.flag_type = flag_type
|
||||
self.default_value = default_value
|
||||
self.evaluation_context = evaluation_context
|
||||
self.client_metadata = client_metadata
|
||||
self.provider_metadata = provider_metadata
|
||||
|
||||
def __setattr__(self, key: str, value: typing.Any) -> None:
|
||||
if hasattr(self, key) and key in (
|
||||
"flag_key",
|
||||
"flag_type",
|
||||
"default_value",
|
||||
"client_metadata",
|
||||
"provider_metadata",
|
||||
):
|
||||
raise AttributeError(f"Attribute {key!r} is immutable")
|
||||
super().__setattr__(key, value)
|
||||
|
||||
|
||||
# https://openfeature.dev/specification/sections/hooks/#requirement-421
|
||||
HookHints = typing.Mapping[
|
||||
str,
|
||||
typing.Union[
|
||||
bool,
|
||||
int,
|
||||
float,
|
||||
str,
|
||||
datetime,
|
||||
Sequence["HookHints"],
|
||||
typing.Mapping[str, "HookHints"],
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
class Hook:
|
||||
def before(
|
||||
self, hook_context: HookContext, hints: HookHints
|
||||
) -> typing.Optional[EvaluationContext]:
|
||||
"""
|
||||
Runs before flag is resolved.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param hints: An immutable mapping of data for users to
|
||||
communicate to the hooks.
|
||||
:return: An EvaluationContext. It will be merged with the
|
||||
EvaluationContext instances from other hooks, the client and API.
|
||||
"""
|
||||
return None
|
||||
|
||||
def after(
|
||||
self,
|
||||
hook_context: HookContext,
|
||||
details: FlagEvaluationDetails[FlagValueType],
|
||||
hints: HookHints,
|
||||
) -> None:
|
||||
"""
|
||||
Runs after a flag is resolved.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param details: Information about how the flag was resolved,
|
||||
including any resolved values.
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
def error(
|
||||
self, hook_context: HookContext, exception: Exception, hints: HookHints
|
||||
) -> None:
|
||||
"""
|
||||
Run when evaluation encounters an error. Errors thrown will be swallowed.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param exception: The exception that was thrown
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
def finally_after(
|
||||
self,
|
||||
hook_context: HookContext,
|
||||
details: FlagEvaluationDetails[FlagValueType],
|
||||
hints: HookHints,
|
||||
) -> None:
|
||||
"""
|
||||
Run after flag evaluation, including any error processing.
|
||||
This will always run. Errors will be swallowed.
|
||||
|
||||
:param hook_context: Information about the particular flag evaluation
|
||||
:param hints: A mapping of data for users to communicate to the hooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
def supports_flag_value_type(self, flag_type: FlagType) -> bool:
|
||||
"""
|
||||
Check to see if the hook supports the particular flag type.
|
||||
|
||||
:param flag_type: particular type of the flag
|
||||
:return: a boolean containing whether the flag type is supported (True)
|
||||
or not (False)
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def add_hooks(hooks: list[Hook]) -> None:
|
||||
global _hooks
|
||||
_hooks = _hooks + hooks
|
||||
|
||||
|
||||
def clear_hooks() -> None:
|
||||
global _hooks
|
||||
_hooks = []
|
||||
|
||||
|
||||
def get_hooks() -> list[Hook]:
|
||||
return _hooks
|
|
@ -2,18 +2,20 @@ import logging
|
|||
import typing
|
||||
from functools import reduce
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
from open_feature.hook import Hook, HookContext, HookType
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
from openfeature.hook import Hook, HookContext, HookHints, HookType
|
||||
|
||||
logger = logging.getLogger("openfeature")
|
||||
|
||||
|
||||
def error_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
exception: Exception,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
hooks: list[Hook],
|
||||
hints: typing.Optional[HookHints] = None,
|
||||
) -> None:
|
||||
kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints}
|
||||
_execute_hooks(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.ERROR, **kwargs
|
||||
|
@ -23,10 +25,11 @@ def error_hooks(
|
|||
def after_all_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
kwargs = {"hook_context": hook_context, "hints": hints}
|
||||
details: FlagEvaluationDetails[typing.Any],
|
||||
hooks: list[Hook],
|
||||
hints: typing.Optional[HookHints] = None,
|
||||
) -> None:
|
||||
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
|
||||
_execute_hooks(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.FINALLY_AFTER, **kwargs
|
||||
)
|
||||
|
@ -35,10 +38,10 @@ def after_all_hooks(
|
|||
def after_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
details: FlagEvaluationDetails,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
):
|
||||
details: FlagEvaluationDetails[typing.Any],
|
||||
hooks: list[Hook],
|
||||
hints: typing.Optional[HookHints] = None,
|
||||
) -> None:
|
||||
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
|
||||
_execute_hooks_unchecked(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.AFTER, **kwargs
|
||||
|
@ -48,14 +51,14 @@ def after_hooks(
|
|||
def before_hooks(
|
||||
flag_type: FlagType,
|
||||
hook_context: HookContext,
|
||||
hooks: typing.List[Hook],
|
||||
hints: typing.Optional[typing.Mapping] = None,
|
||||
hooks: list[Hook],
|
||||
hints: typing.Optional[HookHints] = None,
|
||||
) -> EvaluationContext:
|
||||
kwargs = {"hook_context": hook_context, "hints": hints}
|
||||
executed_hooks = _execute_hooks_unchecked(
|
||||
flag_type=flag_type, hooks=hooks, hook_method=HookType.BEFORE, **kwargs
|
||||
)
|
||||
filtered_hooks = list(filter(lambda hook: hook is not None, executed_hooks))
|
||||
filtered_hooks = [result for result in executed_hooks if result is not None]
|
||||
|
||||
if filtered_hooks:
|
||||
return reduce(lambda a, b: a.merge(b), filtered_hooks)
|
||||
|
@ -64,7 +67,10 @@ def before_hooks(
|
|||
|
||||
|
||||
def _execute_hooks(
|
||||
flag_type: FlagType, hooks: typing.List[Hook], hook_method: HookType, **kwargs
|
||||
flag_type: FlagType,
|
||||
hooks: list[Hook],
|
||||
hook_method: HookType,
|
||||
**kwargs: typing.Any,
|
||||
) -> list:
|
||||
"""
|
||||
Run multiple hooks of any hook type. All of these hooks will be run through an
|
||||
|
@ -76,22 +82,19 @@ def _execute_hooks(
|
|||
:param kwargs: arguments that need to be provided to the hook method
|
||||
:return: a list of results from the applied hook methods
|
||||
"""
|
||||
if hooks:
|
||||
filtered_hooks = list(
|
||||
filter(
|
||||
lambda hook: hook.supports_flag_value_type(flag_type=flag_type), hooks
|
||||
)
|
||||
)
|
||||
return [
|
||||
_execute_hook_checked(hook, hook_method, **kwargs)
|
||||
for hook in filtered_hooks
|
||||
]
|
||||
return []
|
||||
return [
|
||||
_execute_hook_checked(hook, hook_method, **kwargs)
|
||||
for hook in hooks
|
||||
if hook.supports_flag_value_type(flag_type)
|
||||
]
|
||||
|
||||
|
||||
def _execute_hooks_unchecked(
|
||||
flag_type: FlagType, hooks, hook_method: HookType, **kwargs
|
||||
) -> list:
|
||||
flag_type: FlagType,
|
||||
hooks: list[Hook],
|
||||
hook_method: HookType,
|
||||
**kwargs: typing.Any,
|
||||
) -> list[typing.Optional[EvaluationContext]]:
|
||||
"""
|
||||
Execute a single hook without checking whether an exception is thrown. This is
|
||||
used in the before and after hooks since any exception will be caught in the
|
||||
|
@ -103,18 +106,16 @@ def _execute_hooks_unchecked(
|
|||
:param kwargs: arguments that need to be provided to the hook method
|
||||
:return: a list of results from the applied hook methods
|
||||
"""
|
||||
if hooks:
|
||||
filtered_hooks = list(
|
||||
filter(
|
||||
lambda hook: hook.supports_flag_value_type(flag_type=flag_type), hooks
|
||||
)
|
||||
)
|
||||
return [getattr(hook, hook_method.value)(**kwargs) for hook in filtered_hooks]
|
||||
|
||||
return []
|
||||
return [
|
||||
getattr(hook, hook_method.value)(**kwargs)
|
||||
for hook in hooks
|
||||
if hook.supports_flag_value_type(flag_type)
|
||||
]
|
||||
|
||||
|
||||
def _execute_hook_checked(hook: Hook, hook_method: HookType, **kwargs):
|
||||
def _execute_hook_checked(
|
||||
hook: Hook, hook_method: HookType, **kwargs: typing.Any
|
||||
) -> typing.Optional[EvaluationContext]:
|
||||
"""
|
||||
Try and run a single hook and catch any exception thrown. This is used in the
|
||||
after all and error hooks since any error thrown at this point needs to be caught.
|
||||
|
@ -125,6 +126,10 @@ def _execute_hook_checked(hook: Hook, hook_method: HookType, **kwargs):
|
|||
:return: the result of the hook method
|
||||
"""
|
||||
try:
|
||||
return getattr(hook, hook_method.value)(**kwargs)
|
||||
except Exception: # noqa
|
||||
logging.error(f"Exception when running {hook_method.value} hooks")
|
||||
return typing.cast(
|
||||
"typing.Optional[EvaluationContext]",
|
||||
getattr(hook, hook_method.value)(**kwargs),
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception(f"Exception when running {hook_method.value} hooks")
|
||||
return None
|
|
@ -1,3 +1,6 @@
|
|||
import typing
|
||||
|
||||
|
||||
class MappingProxyType(dict):
|
||||
"""
|
||||
MappingProxyType is an immutable dictionary type, written to
|
||||
|
@ -8,16 +11,16 @@ class MappingProxyType(dict):
|
|||
|
||||
When upgrading to Python 3.12, you can update all references
|
||||
from:
|
||||
`from open_feature.immutable_dict.mapping_proxy_type import MappingProxyType`
|
||||
`from openfeature.immutable_dict.mapping_proxy_type import MappingProxyType`
|
||||
|
||||
to:
|
||||
`from types import MappingProxyType`
|
||||
"""
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int: # type:ignore[override]
|
||||
return id(self)
|
||||
|
||||
def _immutable(self, *args, **kws):
|
||||
def _immutable(self, *args: typing.Any, **kws: typing.Any) -> typing.NoReturn:
|
||||
raise TypeError("immutable instance of dictionary")
|
||||
|
||||
__setitem__ = _immutable
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.event import ProviderEvent, ProviderEventDetails
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails
|
||||
from openfeature.hook import Hook
|
||||
|
||||
from .metadata import Metadata
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from openfeature.flag_evaluation import FlagValueType
|
||||
|
||||
__all__ = ["AbstractProvider", "FeatureProvider", "Metadata", "ProviderStatus"]
|
||||
|
||||
|
||||
class ProviderStatus(Enum):
|
||||
NOT_READY = "NOT_READY"
|
||||
READY = "READY"
|
||||
ERROR = "ERROR"
|
||||
STALE = "STALE"
|
||||
FATAL = "FATAL"
|
||||
|
||||
|
||||
class FeatureProvider(typing.Protocol): # pragma: no cover
|
||||
def attach(
|
||||
self,
|
||||
on_emit: typing.Callable[
|
||||
[FeatureProvider, ProviderEvent, ProviderEventDetails], None
|
||||
],
|
||||
) -> None: ...
|
||||
|
||||
def detach(self) -> None: ...
|
||||
|
||||
def initialize(self, evaluation_context: EvaluationContext) -> None: ...
|
||||
|
||||
def shutdown(self) -> None: ...
|
||||
|
||||
def get_metadata(self) -> Metadata: ...
|
||||
|
||||
def get_provider_hooks(self) -> list[Hook]: ...
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]: ...
|
||||
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]: ...
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]: ...
|
||||
|
||||
async def resolve_string_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]: ...
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]: ...
|
||||
|
||||
async def resolve_integer_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]: ...
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]: ...
|
||||
|
||||
async def resolve_float_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]: ...
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]: ...
|
||||
|
||||
async def resolve_object_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]: ...
|
||||
|
||||
|
||||
class AbstractProvider(FeatureProvider):
|
||||
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
||||
# this makes sure to invoke the parent of `FeatureProvider` -> `object`
|
||||
super(FeatureProvider, self).__init__(*args, **kwargs)
|
||||
|
||||
def attach(
|
||||
self,
|
||||
on_emit: typing.Callable[
|
||||
[FeatureProvider, ProviderEvent, ProviderEventDetails], None
|
||||
],
|
||||
) -> None:
|
||||
self._on_emit = on_emit
|
||||
|
||||
def detach(self) -> None:
|
||||
if hasattr(self, "_on_emit"):
|
||||
del self._on_emit
|
||||
|
||||
def initialize(self, evaluation_context: EvaluationContext) -> None:
|
||||
pass
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self) -> Metadata:
|
||||
pass
|
||||
|
||||
def get_provider_hooks(self) -> list[Hook]:
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
pass
|
||||
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return self.resolve_boolean_details(flag_key, default_value, evaluation_context)
|
||||
|
||||
@abstractmethod
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
pass
|
||||
|
||||
async def resolve_string_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
return self.resolve_string_details(flag_key, default_value, evaluation_context)
|
||||
|
||||
@abstractmethod
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
pass
|
||||
|
||||
async def resolve_integer_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
return self.resolve_integer_details(flag_key, default_value, evaluation_context)
|
||||
|
||||
@abstractmethod
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
pass
|
||||
|
||||
async def resolve_float_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
return self.resolve_float_details(flag_key, default_value, evaluation_context)
|
||||
|
||||
@abstractmethod
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
pass
|
||||
|
||||
async def resolve_object_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return self.resolve_object_details(flag_key, default_value, evaluation_context)
|
||||
|
||||
def emit_provider_ready(self, details: ProviderEventDetails) -> None:
|
||||
self.emit(ProviderEvent.PROVIDER_READY, details)
|
||||
|
||||
def emit_provider_configuration_changed(
|
||||
self, details: ProviderEventDetails
|
||||
) -> None:
|
||||
self.emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details)
|
||||
|
||||
def emit_provider_error(self, details: ProviderEventDetails) -> None:
|
||||
self.emit(ProviderEvent.PROVIDER_ERROR, details)
|
||||
|
||||
def emit_provider_stale(self, details: ProviderEventDetails) -> None:
|
||||
self.emit(ProviderEvent.PROVIDER_STALE, details)
|
||||
|
||||
def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None:
|
||||
if hasattr(self, "_on_emit"):
|
||||
self._on_emit(self, event, details)
|
|
@ -0,0 +1,140 @@
|
|||
import typing
|
||||
|
||||
from openfeature._event_support import run_handlers_for_provider
|
||||
from openfeature.evaluation_context import EvaluationContext, get_evaluation_context
|
||||
from openfeature.event import (
|
||||
ProviderEvent,
|
||||
ProviderEventDetails,
|
||||
)
|
||||
from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError
|
||||
from openfeature.provider import FeatureProvider, ProviderStatus
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
_default_provider: FeatureProvider
|
||||
_providers: dict[str, FeatureProvider]
|
||||
_provider_status: dict[FeatureProvider, ProviderStatus]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._default_provider = NoOpProvider()
|
||||
self._providers = {}
|
||||
self._provider_status = {
|
||||
self._default_provider: ProviderStatus.READY,
|
||||
}
|
||||
|
||||
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
|
||||
if provider is None:
|
||||
raise GeneralError(error_message="No provider")
|
||||
providers = self._providers
|
||||
if domain in providers:
|
||||
old_provider = providers[domain]
|
||||
del providers[domain]
|
||||
if old_provider not in providers.values():
|
||||
self._shutdown_provider(old_provider)
|
||||
if provider not in providers.values():
|
||||
self._initialize_provider(provider)
|
||||
providers[domain] = provider
|
||||
|
||||
def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider:
|
||||
if domain is None:
|
||||
return self._default_provider
|
||||
return self._providers.get(domain, self._default_provider)
|
||||
|
||||
def set_default_provider(self, provider: FeatureProvider) -> None:
|
||||
if provider is None:
|
||||
raise GeneralError(error_message="No provider")
|
||||
if self._default_provider:
|
||||
self._shutdown_provider(self._default_provider)
|
||||
self._default_provider = provider
|
||||
self._initialize_provider(provider)
|
||||
|
||||
def get_default_provider(self) -> FeatureProvider:
|
||||
return self._default_provider
|
||||
|
||||
def clear_providers(self) -> None:
|
||||
self.shutdown()
|
||||
self._providers.clear()
|
||||
self._default_provider = NoOpProvider()
|
||||
self._provider_status = {
|
||||
self._default_provider: ProviderStatus.READY,
|
||||
}
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for provider in {self._default_provider, *self._providers.values()}:
|
||||
self._shutdown_provider(provider)
|
||||
|
||||
def _get_evaluation_context(self) -> EvaluationContext:
|
||||
return get_evaluation_context()
|
||||
|
||||
def _initialize_provider(self, provider: FeatureProvider) -> None:
|
||||
provider.attach(self.dispatch_event)
|
||||
try:
|
||||
if hasattr(provider, "initialize"):
|
||||
provider.initialize(self._get_evaluation_context())
|
||||
self.dispatch_event(
|
||||
provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails()
|
||||
)
|
||||
except Exception as err:
|
||||
error_code = (
|
||||
err.error_code
|
||||
if isinstance(err, OpenFeatureError)
|
||||
else ErrorCode.GENERAL
|
||||
)
|
||||
self.dispatch_event(
|
||||
provider,
|
||||
ProviderEvent.PROVIDER_ERROR,
|
||||
ProviderEventDetails(
|
||||
message=f"Provider initialization failed: {err}",
|
||||
error_code=error_code,
|
||||
),
|
||||
)
|
||||
|
||||
def _shutdown_provider(self, provider: FeatureProvider) -> None:
|
||||
try:
|
||||
if hasattr(provider, "shutdown"):
|
||||
provider.shutdown()
|
||||
self._provider_status[provider] = ProviderStatus.NOT_READY
|
||||
except Exception as err:
|
||||
self.dispatch_event(
|
||||
provider,
|
||||
ProviderEvent.PROVIDER_ERROR,
|
||||
ProviderEventDetails(
|
||||
message=f"Provider shutdown failed: {err}",
|
||||
error_code=ErrorCode.PROVIDER_FATAL,
|
||||
),
|
||||
)
|
||||
provider.detach()
|
||||
|
||||
def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus:
|
||||
return self._provider_status.get(provider, ProviderStatus.NOT_READY)
|
||||
|
||||
def dispatch_event(
|
||||
self,
|
||||
provider: FeatureProvider,
|
||||
event: ProviderEvent,
|
||||
details: ProviderEventDetails,
|
||||
) -> None:
|
||||
self._update_provider_status(provider, event, details)
|
||||
run_handlers_for_provider(provider, event, details)
|
||||
|
||||
def _update_provider_status(
|
||||
self,
|
||||
provider: FeatureProvider,
|
||||
event: ProviderEvent,
|
||||
details: ProviderEventDetails,
|
||||
) -> None:
|
||||
if event == ProviderEvent.PROVIDER_READY:
|
||||
self._provider_status[provider] = ProviderStatus.READY
|
||||
elif event == ProviderEvent.PROVIDER_STALE:
|
||||
self._provider_status[provider] = ProviderStatus.STALE
|
||||
elif event == ProviderEvent.PROVIDER_ERROR:
|
||||
status = (
|
||||
ProviderStatus.FATAL
|
||||
if details.error_code == ErrorCode.PROVIDER_FATAL
|
||||
else ProviderStatus.ERROR
|
||||
)
|
||||
self._provider_status[provider] = status
|
||||
|
||||
|
||||
provider_registry = ProviderRegistry()
|
|
@ -0,0 +1,187 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from openfeature._backports.strenum import StrEnum
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from openfeature.provider import AbstractProvider, Metadata
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from openfeature.flag_evaluation import FlagMetadata, FlagValueType
|
||||
from openfeature.hook import Hook
|
||||
|
||||
PASSED_IN_DEFAULT = "Passed in default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InMemoryMetadata(Metadata):
|
||||
name: str = "In-Memory Provider"
|
||||
|
||||
|
||||
T_co = typing.TypeVar("T_co", covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InMemoryFlag(typing.Generic[T_co]):
|
||||
class State(StrEnum):
|
||||
ENABLED = "ENABLED"
|
||||
DISABLED = "DISABLED"
|
||||
|
||||
default_variant: str
|
||||
variants: dict[str, T_co]
|
||||
flag_metadata: FlagMetadata = field(default_factory=dict)
|
||||
state: State = State.ENABLED
|
||||
context_evaluator: typing.Optional[
|
||||
typing.Callable[
|
||||
[InMemoryFlag[T_co], EvaluationContext], FlagResolutionDetails[T_co]
|
||||
]
|
||||
] = None
|
||||
|
||||
def resolve(
|
||||
self, evaluation_context: typing.Optional[EvaluationContext]
|
||||
) -> FlagResolutionDetails[T_co]:
|
||||
if self.context_evaluator:
|
||||
return self.context_evaluator(
|
||||
self, evaluation_context or EvaluationContext()
|
||||
)
|
||||
|
||||
return FlagResolutionDetails(
|
||||
value=self.variants[self.default_variant],
|
||||
reason=Reason.STATIC,
|
||||
variant=self.default_variant,
|
||||
flag_metadata=self.flag_metadata,
|
||||
)
|
||||
|
||||
|
||||
FlagStorage = dict[str, InMemoryFlag[typing.Any]]
|
||||
|
||||
V = typing.TypeVar("V")
|
||||
|
||||
|
||||
class InMemoryProvider(AbstractProvider):
|
||||
_flags: FlagStorage
|
||||
|
||||
def __init__(self, flags: FlagStorage) -> None:
|
||||
self._flags = flags.copy()
|
||||
|
||||
def get_metadata(self) -> Metadata:
|
||||
return InMemoryMetadata()
|
||||
|
||||
def get_provider_hooks(self) -> list[Hook]:
|
||||
return []
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return await self._resolve_async(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
async def resolve_string_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
return await self._resolve_async(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
async def resolve_integer_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
return await self._resolve_async(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
async def resolve_float_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
return await self._resolve_async(flag_key, default_value, evaluation_context)
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
||||
|
||||
async def resolve_object_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return await self._resolve_async(flag_key, default_value, evaluation_context)
|
||||
|
||||
def _resolve(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: V,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> FlagResolutionDetails[V]:
|
||||
flag = self._flags.get(flag_key)
|
||||
if flag is None:
|
||||
return FlagResolutionDetails(
|
||||
value=default_value,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.FLAG_NOT_FOUND,
|
||||
error_message=f"Flag '{flag_key}' not found",
|
||||
)
|
||||
return flag.resolve(evaluation_context)
|
||||
|
||||
async def _resolve_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: V,
|
||||
evaluation_context: typing.Optional[EvaluationContext],
|
||||
) -> FlagResolutionDetails[V]:
|
||||
return self._resolve(flag_key, default_value, evaluation_context)
|
|
@ -1,6 +1,6 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from open_feature.provider.metadata import Metadata
|
||||
from openfeature.provider.metadata import Metadata
|
||||
|
||||
|
||||
@dataclass
|
|
@ -1,11 +1,17 @@
|
|||
import typing
|
||||
from __future__ import annotations
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.metadata import Metadata
|
||||
from open_feature.provider.no_op_metadata import NoOpMetadata
|
||||
from open_feature.provider.provider import AbstractProvider
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from openfeature.provider import AbstractProvider
|
||||
from openfeature.provider.no_op_metadata import NoOpMetadata
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagValueType
|
||||
from openfeature.hook import Hook
|
||||
from openfeature.provider import Metadata
|
||||
|
||||
PASSED_IN_DEFAULT = "Passed in default"
|
||||
|
||||
|
@ -14,7 +20,7 @@ class NoOpProvider(AbstractProvider):
|
|||
def get_metadata(self) -> Metadata:
|
||||
return NoOpMetadata()
|
||||
|
||||
def get_provider_hooks(self) -> typing.List[Hook]:
|
||||
def get_provider_hooks(self) -> list[Hook]:
|
||||
return []
|
||||
|
||||
def resolve_boolean_details(
|
||||
|
@ -68,9 +74,13 @@ class NoOpProvider(AbstractProvider):
|
|||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: typing.Union[dict, list],
|
||||
default_value: typing.Union[
|
||||
Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
||||
],
|
||||
evaluation_context: typing.Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[typing.Union[dict, list]]:
|
||||
) -> FlagResolutionDetails[
|
||||
typing.Union[Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
||||
]:
|
||||
return FlagResolutionDetails(
|
||||
value=default_value,
|
||||
reason=Reason.DEFAULT,
|
|
@ -0,0 +1,11 @@
|
|||
import warnings
|
||||
|
||||
from openfeature.provider import AbstractProvider
|
||||
|
||||
__all__ = ["AbstractProvider"]
|
||||
|
||||
warnings.warn(
|
||||
"openfeature.provider.provider is deprecated, use openfeature.provider instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=1,
|
||||
)
|
|
@ -0,0 +1,75 @@
|
|||
import typing
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
|
||||
from openfeature.hook import HookContext
|
||||
from openfeature.telemetry.attributes import TelemetryAttribute
|
||||
from openfeature.telemetry.body import TelemetryBodyField
|
||||
from openfeature.telemetry.metadata import TelemetryFlagMetadata
|
||||
|
||||
__all__ = [
|
||||
"EvaluationEvent",
|
||||
"TelemetryAttribute",
|
||||
"TelemetryBodyField",
|
||||
"TelemetryFlagMetadata",
|
||||
"create_evaluation_event",
|
||||
]
|
||||
|
||||
FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"
|
||||
|
||||
T_co = typing.TypeVar("T_co", covariant=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluationEvent(typing.Generic[T_co]):
|
||||
name: str
|
||||
attributes: Mapping[TelemetryAttribute, typing.Union[str, T_co]]
|
||||
body: Mapping[TelemetryBodyField, T_co]
|
||||
|
||||
|
||||
def create_evaluation_event(
|
||||
hook_context: HookContext, details: FlagEvaluationDetails[T_co]
|
||||
) -> EvaluationEvent[T_co]:
|
||||
attributes = {
|
||||
TelemetryAttribute.KEY: details.flag_key,
|
||||
TelemetryAttribute.EVALUATION_REASON: (
|
||||
details.reason or Reason.UNKNOWN
|
||||
).lower(),
|
||||
}
|
||||
body = {}
|
||||
|
||||
if variant := details.variant:
|
||||
attributes[TelemetryAttribute.VARIANT] = variant
|
||||
else:
|
||||
body[TelemetryBodyField.VALUE] = details.value
|
||||
|
||||
context_id = details.flag_metadata.get(
|
||||
TelemetryFlagMetadata.CONTEXT_ID, hook_context.evaluation_context.targeting_key
|
||||
)
|
||||
if context_id:
|
||||
attributes[TelemetryAttribute.CONTEXT_ID] = typing.cast("str", context_id)
|
||||
|
||||
if set_id := details.flag_metadata.get(TelemetryFlagMetadata.FLAG_SET_ID):
|
||||
attributes[TelemetryAttribute.SET_ID] = typing.cast("str", set_id)
|
||||
|
||||
if version := details.flag_metadata.get(TelemetryFlagMetadata.VERSION):
|
||||
attributes[TelemetryAttribute.VERSION] = typing.cast("str", version)
|
||||
|
||||
if metadata := hook_context.provider_metadata:
|
||||
attributes[TelemetryAttribute.PROVIDER_NAME] = metadata.name
|
||||
|
||||
if details.reason == Reason.ERROR:
|
||||
attributes[TelemetryAttribute.ERROR_TYPE] = (
|
||||
details.error_code or ErrorCode.GENERAL
|
||||
).lower()
|
||||
|
||||
if err_msg := details.error_message:
|
||||
attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] = err_msg
|
||||
|
||||
return EvaluationEvent(
|
||||
name=FLAG_EVALUATION_EVENT_NAME,
|
||||
attributes=attributes,
|
||||
body=body,
|
||||
)
|
|
@ -0,0 +1,19 @@
|
|||
from openfeature._backports.strenum import StrEnum
|
||||
|
||||
|
||||
class TelemetryAttribute(StrEnum):
|
||||
"""
|
||||
The attributes of an OpenTelemetry compliant event for flag evaluation.
|
||||
|
||||
See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
|
||||
"""
|
||||
|
||||
CONTEXT_ID = "feature_flag.context.id"
|
||||
ERROR_TYPE = "error.type"
|
||||
EVALUATION_ERROR_MESSAGE = "feature_flag.evaluation.error.message"
|
||||
EVALUATION_REASON = "feature_flag.evaluation.reason"
|
||||
KEY = "feature_flag.key"
|
||||
PROVIDER_NAME = "feature_flag.provider_name"
|
||||
SET_ID = "feature_flag.set.id"
|
||||
VARIANT = "feature_flag.variant"
|
||||
VERSION = "feature_flag.version"
|
|
@ -0,0 +1,11 @@
|
|||
from openfeature._backports.strenum import StrEnum
|
||||
|
||||
|
||||
class TelemetryBodyField(StrEnum):
|
||||
"""
|
||||
OpenTelemetry event body fields.
|
||||
|
||||
See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
|
||||
"""
|
||||
|
||||
VALUE = "value"
|
|
@ -0,0 +1,13 @@
|
|||
from openfeature._backports.strenum import StrEnum
|
||||
|
||||
|
||||
class TelemetryFlagMetadata(StrEnum):
|
||||
"""
|
||||
Well-known flag metadata attributes for telemetry events.
|
||||
|
||||
See: https://openfeature.dev/specification/appendix-d/#flag-metadata
|
||||
"""
|
||||
|
||||
CONTEXT_ID = "contextId"
|
||||
FLAG_SET_ID = "flagSetId"
|
||||
VERSION = "version"
|
|
@ -0,0 +1,40 @@
|
|||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context.context_var_transaction_context_propagator import (
|
||||
ContextVarsTransactionContextPropagator,
|
||||
)
|
||||
from openfeature.transaction_context.no_op_transaction_context_propagator import (
|
||||
NoOpTransactionContextPropagator,
|
||||
)
|
||||
from openfeature.transaction_context.transaction_context_propagator import (
|
||||
TransactionContextPropagator,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContextVarsTransactionContextPropagator",
|
||||
"TransactionContextPropagator",
|
||||
"get_transaction_context",
|
||||
"set_transaction_context",
|
||||
"set_transaction_context_propagator",
|
||||
]
|
||||
|
||||
_evaluation_transaction_context_propagator: TransactionContextPropagator = (
|
||||
NoOpTransactionContextPropagator()
|
||||
)
|
||||
|
||||
|
||||
def set_transaction_context_propagator(
|
||||
transaction_context_propagator: TransactionContextPropagator,
|
||||
) -> None:
|
||||
global _evaluation_transaction_context_propagator
|
||||
_evaluation_transaction_context_propagator = transaction_context_propagator
|
||||
|
||||
|
||||
def get_transaction_context() -> EvaluationContext:
|
||||
return _evaluation_transaction_context_propagator.get_transaction_context()
|
||||
|
||||
|
||||
def set_transaction_context(evaluation_context: EvaluationContext) -> None:
|
||||
global _evaluation_transaction_context_propagator
|
||||
_evaluation_transaction_context_propagator.set_transaction_context(
|
||||
evaluation_context
|
||||
)
|
|
@ -0,0 +1,24 @@
|
|||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context.transaction_context_propagator import (
|
||||
TransactionContextPropagator,
|
||||
)
|
||||
|
||||
|
||||
class ContextVarsTransactionContextPropagator(TransactionContextPropagator):
|
||||
_transaction_context_var: ContextVar[Optional[EvaluationContext]] = ContextVar(
|
||||
"transaction_context", default=None
|
||||
)
|
||||
|
||||
def get_transaction_context(self) -> EvaluationContext:
|
||||
context = self._transaction_context_var.get()
|
||||
if context is None:
|
||||
context = EvaluationContext()
|
||||
self._transaction_context_var.set(context)
|
||||
|
||||
return context
|
||||
|
||||
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
|
||||
self._transaction_context_var.set(transaction_context)
|
|
@ -0,0 +1,12 @@
|
|||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context.transaction_context_propagator import (
|
||||
TransactionContextPropagator,
|
||||
)
|
||||
|
||||
|
||||
class NoOpTransactionContextPropagator(TransactionContextPropagator):
|
||||
def get_transaction_context(self) -> EvaluationContext:
|
||||
return EvaluationContext()
|
||||
|
||||
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
|
||||
pass
|
|
@ -0,0 +1,11 @@
|
|||
import typing
|
||||
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
|
||||
|
||||
class TransactionContextPropagator(typing.Protocol):
|
||||
def get_transaction_context(self) -> EvaluationContext: ...
|
||||
|
||||
def set_transaction_context(
|
||||
self, transaction_context: EvaluationContext
|
||||
) -> None: ...
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.8.2"
|
120
pyproject.toml
120
pyproject.toml
|
@ -1,13 +1,13 @@
|
|||
# pyproject.toml
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
requires = ["uv_build~=0.8.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "openfeature_sdk"
|
||||
version = "0.2.0"
|
||||
version = "0.8.2"
|
||||
description = "Standardizing Feature Flagging for Everyone"
|
||||
readme = "readme.md"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }]
|
||||
license = { file = "LICENSE" }
|
||||
classifiers = [
|
||||
|
@ -15,16 +15,112 @@ classifiers = [
|
|||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
keywords = []
|
||||
keywords = [
|
||||
"openfeature",
|
||||
"feature",
|
||||
"flags",
|
||||
"toggles",
|
||||
]
|
||||
dependencies = []
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["black", "flake8", "isort", "pip-tools", "pytest", "pre-commit"]
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/open-feature/python-sdk"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"behave",
|
||||
"coverage[toml]>=6.5",
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pre-commit"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
required-version = "~=0.8.0"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "openfeature"
|
||||
module-root = ""
|
||||
namespace = true
|
||||
|
||||
[tool.mypy]
|
||||
files = "openfeature"
|
||||
|
||||
python_version = "3.9" # should be identical to the minimum supported version
|
||||
namespace_packages = true
|
||||
explicit_package_bases = true
|
||||
local_partial_types = true # will become the new default from version 2
|
||||
pretty = true
|
||||
strict = true
|
||||
disallow_any_generics = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if TYPE_CHECKING:",
|
||||
"if typing.TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
]
|
||||
target-version = "py39"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"A",
|
||||
"B",
|
||||
"C4",
|
||||
"C90",
|
||||
"E",
|
||||
"F",
|
||||
"FLY",
|
||||
"FURB",
|
||||
"I",
|
||||
"LOG",
|
||||
"N",
|
||||
"PERF",
|
||||
"PGH",
|
||||
"PLC",
|
||||
"PLR0913",
|
||||
"PLR0915",
|
||||
"RUF",
|
||||
"S",
|
||||
"SIM",
|
||||
"T10",
|
||||
"T20",
|
||||
"UP",
|
||||
"W",
|
||||
"YTT",
|
||||
]
|
||||
ignore = [
|
||||
"E501", # the formatter will handle any too long line
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*" = ["PLR0913", "S101"]
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
max-args = 6
|
||||
max-statements = 30
|
||||
|
||||
[tool.ruff.lint.pyupgrade]
|
||||
# Preserve types, even if a file imports `from __future__ import annotations`.
|
||||
keep-runtime-typing = true
|
||||
|
||||
[project.scripts]
|
||||
# workaround while UV doesn't support scripts directly in the pyproject.toml
|
||||
# see: https://github.com/astral-sh/uv/issues/5903
|
||||
test = "scripts.scripts:test"
|
||||
test-cov = "scripts.scripts:test_cov"
|
||||
cov-report = "scripts.scripts:cov_report"
|
||||
cov = "scripts.scripts:cov"
|
||||
e2e = "scripts.scripts:e2e"
|
||||
precommit = "scripts.scripts:precommit"
|
201
readme.md
201
readme.md
|
@ -1,201 +0,0 @@
|
|||
<!-- markdownlint-disable MD033 -->
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/white/openfeature-horizontal-white.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/black/openfeature-horizontal-black.svg">
|
||||
<img align="center" alt="OpenFeature Logo">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<h2 align="center">OpenFeature Python SDK</h2>
|
||||
|
||||
[](https://badge.fury.io/py/openfeature-sdk)
|
||||

|
||||
[](https://www.repostatus.org/#wip)
|
||||
[](https://github.com/open-feature/spec/tree/v0.3.0)
|
||||
[](https://github.com/open-feature/python-sdk/actions/workflows/merge.yml)
|
||||
[](https://codecov.io/gh/open-feature/python-sdk)
|
||||
|
||||
> ⚠️ Development is in progress, but there's not a stable release available. ⚠️
|
||||
|
||||
This is the Python implementation of [OpenFeature](https://openfeature.dev), a vendor-agnostic abstraction library for evaluating feature flags.
|
||||
|
||||
We support multiple data types for flags (numbers, strings, booleans, objects) as well as hooks, which can alter the lifecycle of a flag evaluation.
|
||||
|
||||
This library is intended to be used in server-side contexts and has not been evaluated for use in mobile devices.
|
||||
|
||||
## 🔍 Requirements:
|
||||
|
||||
- Python 3.8+
|
||||
|
||||
## 📦 Installation:
|
||||
|
||||
### Add it to your build
|
||||
|
||||
<!---x-release-please-start-version-->
|
||||
|
||||
Pip install
|
||||
|
||||
```bash
|
||||
pip install openfeature-sdk==0.2.0
|
||||
```
|
||||
|
||||
requirements.txt
|
||||
|
||||
```bash
|
||||
openfeature-sdk==0.2.0
|
||||
```
|
||||
|
||||
```python
|
||||
pip install requirements.txt
|
||||
```
|
||||
|
||||
<!---x-release-please-end-->
|
||||
|
||||
## 🌟 Features:
|
||||
|
||||
- support for various backend [providers](https://openfeature.dev/docs/reference/concepts/provider)
|
||||
- easy integration and extension via [hooks](https://openfeature.dev/docs/reference/concepts/hooks)
|
||||
- bool, string, numeric, and object flag types
|
||||
- [context-aware](https://openfeature.dev/docs/reference/concepts/evaluation-context) evaluation
|
||||
|
||||
## 🚀 Usage:
|
||||
|
||||
### Configure it
|
||||
|
||||
In order to use the sdk there is some minor configuration. Follow the script below:
|
||||
|
||||
```python
|
||||
from open_feature import api
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
api.set_provider(NoOpProvider())
|
||||
open_feature_client = api.get_client()
|
||||
```
|
||||
|
||||
### Basics:
|
||||
|
||||
While Boolean provides the simplest introduction, we offer a variety of flag types.
|
||||
|
||||
```python
|
||||
# Depending on the flag type, use one of the methods below
|
||||
flag_key = "PROVIDER_FLAG"
|
||||
boolean_result = open_feature_client.get_boolean_value(key=flag_key,default_value=False)
|
||||
integer_result = open_feature_client.get_integer_value(key=flag_key,default_value=-1)
|
||||
float_result = open_feature_client.get_float_value(key=flag_key,default_value=-1)
|
||||
string_result = open_feature_client.get_string_value(key=flag_key,default_value="")
|
||||
object_result = open_feature_client.get_object_value(key=flag_key,default_value={})
|
||||
```
|
||||
|
||||
You can also bind a provider to a specific client by name instead of setting that provider globally:
|
||||
|
||||
```python
|
||||
|
||||
api.set_provider(NoOpProvider())
|
||||
```
|
||||
|
||||
Each provider class may have further setup required i.e. secret keys, environment variables etc
|
||||
|
||||
### Context-aware evaluation:
|
||||
|
||||
Sometimes the value of a flag must take into account some dynamic criteria about the application or user, such as the user location, IP, email address, or the location of the server.
|
||||
In OpenFeature, we refer to this as [`targeting`](https://openfeature.dev/specification/glossary#targeting).
|
||||
If the flag system you're using supports targeting, you can provide the input data using the `EvaluationContext`.
|
||||
|
||||
```python
|
||||
from open_feature.api import (
|
||||
get_client,
|
||||
get_provider,
|
||||
set_provider
|
||||
get_evaluation_context,
|
||||
set_evaluation_context,
|
||||
)
|
||||
|
||||
global_context = EvaluationContext(
|
||||
targeting_key="targeting_key1", attributes={"application": "value1"}
|
||||
)
|
||||
request_context = EvaluationContext(
|
||||
targeting_key="targeting_key2", attributes={"email": request.form['email']}
|
||||
)
|
||||
|
||||
## set global context
|
||||
set_evaluation_context(first_context)
|
||||
|
||||
# merge second context
|
||||
client = get_client(name="No-op Provider", version="0.5.2")
|
||||
client.get_string_value("email", None, request_context)
|
||||
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
TBD (See Issue [#131](https://github.com/open-feature/python-sdk/issues/131))
|
||||
|
||||
### Providers:
|
||||
|
||||
To develop a provider, you need to create a new project and include the OpenFeature SDK as a dependency. 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. Finally, you’ll then need to write the provider itself. This can be accomplished by implementing the `Provider` interface exported by the OpenFeature SDK.
|
||||
|
||||
See [here](https://openfeature.dev/ecosystem) for a catalog of available providers.
|
||||
|
||||
### Hooks:
|
||||
|
||||
A hook is a mechanism that allows for adding arbitrary behavior at well-defined points of the flag evaluation life-cycle. Use cases include validating the resolved flag value, modifying or adding data to the evaluation context, logging, telemetry, and tracking.
|
||||
|
||||
```python
|
||||
from open_feature.hook import Hook
|
||||
|
||||
class MyHook(Hook):
|
||||
def after(self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict):
|
||||
print("This runs after the flag has been evaluated")
|
||||
|
||||
|
||||
# set global hooks at the API-level
|
||||
from open_feature.api import add_hooks
|
||||
add_hooks([MyHook()])
|
||||
|
||||
# or configure them in the client
|
||||
client = OpenFeatureClient()
|
||||
client.add_hooks([MyHook()])
|
||||
```
|
||||
|
||||
See [here](https://openfeature.dev/ecosystem) for a catalog of available hooks.
|
||||
|
||||
### Logging:
|
||||
|
||||
TBD
|
||||
|
||||
## ⭐️ Support the project
|
||||
|
||||
- Give this repo a ⭐️!
|
||||
- Follow us on social media:
|
||||
- Twitter: [@openfeature](https://twitter.com/openfeature)
|
||||
- LinkedIn: [OpenFeature](https://www.linkedin.com/company/openfeature/)
|
||||
- Join us on [Slack](https://cloud-native.slack.com/archives/C0344AANLA1)
|
||||
- For more check out our [community page](https://openfeature.dev/community/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Interested in contributing? Great, we'd love your help! To get started, take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide.
|
||||
|
||||
### Thanks to everyone that has already contributed
|
||||
|
||||
<!-- TODO: update with correct repo -->
|
||||
<a href="https://github.com/open-feature/python-sdk/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=open-feature/python-sdk" alt="Pictures of the folks who have contributed to the project" />
|
||||
</a>
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
|
||||
## Contacting us
|
||||
|
||||
We hold regular meetings which you can see [here](https://github.com/open-feature/community/#meetings-and-events).
|
||||
|
||||
We are also present on the `#openfeature` channel in the [CNCF slack](https://slack.cncf.io/).
|
||||
|
||||
## 📜 License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
|
||||
<!-- TODO: add FOSSA widget -->
|
||||
|
||||
[openfeature-website]: https://openfeature.dev
|
|
@ -9,8 +9,63 @@
|
|||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"extra-files": [
|
||||
"readme.md"
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"changelog-sections": [
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "🐛 Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "✨ New Features"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "🧹 Chore"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "📚 Documentation"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "🚀 Performance"
|
||||
},
|
||||
{
|
||||
"type": "build",
|
||||
"hidden": true,
|
||||
"section": "🛠️ Build"
|
||||
},
|
||||
{
|
||||
"type": "deps",
|
||||
"section": "📦 Dependencies"
|
||||
},
|
||||
{
|
||||
"type": "ci",
|
||||
"hidden": true,
|
||||
"section": "🚦 CI"
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"section": "🔄 Refactoring"
|
||||
},
|
||||
{
|
||||
"type": "revert",
|
||||
"section": "🔙 Reverts"
|
||||
},
|
||||
{
|
||||
"type": "style",
|
||||
"hidden": true,
|
||||
"section": "🎨 Styling"
|
||||
},
|
||||
{
|
||||
"type": "test",
|
||||
"hidden": true,
|
||||
"section": "🧪 Tests"
|
||||
}
|
||||
],
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
}
|
||||
|
|
|
@ -1,27 +1,15 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:base"
|
||||
"github>open-feature/community-tooling"
|
||||
],
|
||||
"semanticCommits": "enabled",
|
||||
"pip_requirements": {
|
||||
"fileMatch": ["requirements.txt", "requirements-dev.txt"]
|
||||
"pep621": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Automerge non-major updates",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
"pre-commit": {
|
||||
"enabled": true
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
pylint
|
||||
pytest
|
||||
pytest-mock
|
||||
black
|
||||
pip-tools
|
||||
pre-commit
|
||||
flake8
|
||||
pytest-mock
|
||||
coverage
|
||||
behave
|
|
@ -1,102 +0,0 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements-dev.in
|
||||
#
|
||||
astroid==2.15.6
|
||||
# via pylint
|
||||
behave==1.2.6
|
||||
# via -r requirements-dev.in
|
||||
black==23.9.0
|
||||
# via -r requirements-dev.in
|
||||
build==1.0.3
|
||||
# via pip-tools
|
||||
cfgv==3.4.0
|
||||
# via pre-commit
|
||||
click==8.1.7
|
||||
# via
|
||||
# black
|
||||
# pip-tools
|
||||
coverage==7.3.1
|
||||
# via -r requirements-dev.in
|
||||
dill==0.3.7
|
||||
# via pylint
|
||||
distlib==0.3.7
|
||||
# via virtualenv
|
||||
filelock==3.12.3
|
||||
# via virtualenv
|
||||
flake8==6.0.0
|
||||
# via -r requirements-dev.in
|
||||
identify==2.5.27
|
||||
# via pre-commit
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
isort==5.12.0
|
||||
# via pylint
|
||||
lazy-object-proxy==1.9.0
|
||||
# via astroid
|
||||
mccabe==0.7.0
|
||||
# via
|
||||
# flake8
|
||||
# pylint
|
||||
mypy-extensions==1.0.0
|
||||
# via black
|
||||
nodeenv==1.8.0
|
||||
# via pre-commit
|
||||
packaging==23.1
|
||||
# via
|
||||
# black
|
||||
# build
|
||||
# pytest
|
||||
parse==1.19.1
|
||||
# via
|
||||
# behave
|
||||
# parse-type
|
||||
parse-type==0.6.2
|
||||
# via behave
|
||||
pathspec==0.11.2
|
||||
# via black
|
||||
pip-tools==7.3.0
|
||||
# via -r requirements-dev.in
|
||||
platformdirs==3.10.0
|
||||
# via
|
||||
# black
|
||||
# pylint
|
||||
# virtualenv
|
||||
pluggy==1.3.0
|
||||
# via pytest
|
||||
pre-commit==3.4.0
|
||||
# via -r requirements-dev.in
|
||||
pycodestyle==2.10.0
|
||||
# via flake8
|
||||
pyflakes==3.0.1
|
||||
# via flake8
|
||||
pylint==2.17.5
|
||||
# via -r requirements-dev.in
|
||||
pyproject-hooks==1.0.0
|
||||
# via build
|
||||
pytest==7.4.2
|
||||
# via
|
||||
# -r requirements-dev.in
|
||||
# pytest-mock
|
||||
pytest-mock==3.11.1
|
||||
# via -r requirements-dev.in
|
||||
pyyaml==6.0.1
|
||||
# via pre-commit
|
||||
six==1.16.0
|
||||
# via
|
||||
# behave
|
||||
# parse-type
|
||||
tomlkit==0.12.1
|
||||
# via pylint
|
||||
virtualenv==20.24.5
|
||||
# via pre-commit
|
||||
wheel==0.41.2
|
||||
# via pip-tools
|
||||
wrapt==1.15.0
|
||||
# via astroid
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
# setuptools
|
|
@ -1,6 +0,0 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile with python 3.10
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile requirements.in
|
||||
#
|
|
@ -0,0 +1,38 @@
|
|||
# ruff: noqa: S602, S607
|
||||
import subprocess
|
||||
|
||||
|
||||
def test():
|
||||
"""Run pytest tests."""
|
||||
subprocess.run("pytest tests", shell=True, check=True)
|
||||
|
||||
|
||||
def test_cov():
|
||||
"""Run tests with coverage."""
|
||||
subprocess.run("coverage run -m pytest tests", shell=True, check=True)
|
||||
|
||||
|
||||
def cov_report():
|
||||
"""Generate coverage report."""
|
||||
subprocess.run("coverage xml", shell=True, check=True)
|
||||
|
||||
|
||||
def cov():
|
||||
"""Run tests with coverage and generate report."""
|
||||
test_cov()
|
||||
cov_report()
|
||||
|
||||
|
||||
def e2e():
|
||||
"""Run end-to-end tests."""
|
||||
subprocess.run("git submodule update --init --recursive", shell=True, check=True)
|
||||
subprocess.run(
|
||||
"cp spec/specification/assets/gherkin/* tests/features/", shell=True, check=True
|
||||
)
|
||||
subprocess.run("behave tests/features/", shell=True, check=True)
|
||||
subprocess.run("rm tests/features/*.feature", shell=True, check=True)
|
||||
|
||||
|
||||
def precommit():
|
||||
"""Run pre-commit hooks."""
|
||||
subprocess.run("uv run pre-commit run --all-files", shell=True, check=True)
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88
|
|
@ -1 +0,0 @@
|
|||
Subproject commit bd13458f7e3587ab2ed98b8017bea3c2eb472cc9
|
|
@ -1,17 +1,16 @@
|
|||
import pytest
|
||||
|
||||
from open_feature import api
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature import api
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_provider():
|
||||
def clear_providers():
|
||||
"""
|
||||
For tests that use set_provider(), we need to clear the provider to avoid issues
|
||||
in other tests.
|
||||
"""
|
||||
yield
|
||||
_provider = None # noqa: F841
|
||||
api.clear_providers()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from open_feature.evaluation_context import EvaluationContext
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
|
||||
|
||||
def test_empty_evaluation_context_can_be_merged_with_non_empty_context():
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from open_feature.provider.in_memory_provider import InMemoryFlag
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from openfeature.provider.in_memory_provider import InMemoryFlag
|
||||
|
||||
|
||||
def context_func(flag: InMemoryFlag, evaluation_context: EvaluationContext):
|
||||
|
@ -22,35 +22,30 @@ def context_func(flag: InMemoryFlag, evaluation_context: EvaluationContext):
|
|||
|
||||
IN_MEMORY_FLAGS = {
|
||||
"boolean-flag": InMemoryFlag(
|
||||
flag_key="boolean-flag",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="on",
|
||||
variants={"on": True, "off": False},
|
||||
context_evaluator=None,
|
||||
),
|
||||
"string-flag": InMemoryFlag(
|
||||
flag_key="string-flag",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="greeting",
|
||||
variants={"greeting": "hi", "parting": "bye"},
|
||||
context_evaluator=None,
|
||||
),
|
||||
"integer-flag": InMemoryFlag(
|
||||
flag_key="integer-flag",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="ten",
|
||||
variants={"one": 1, "ten": 10},
|
||||
context_evaluator=None,
|
||||
),
|
||||
"float-flag": InMemoryFlag(
|
||||
flag_key="float-flag",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="half",
|
||||
variants={"tenth": 0.1, "half": 0.5},
|
||||
context_evaluator=None,
|
||||
),
|
||||
"object-flag": InMemoryFlag(
|
||||
flag_key="object-flag",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="template",
|
||||
variants={
|
||||
|
@ -64,16 +59,26 @@ IN_MEMORY_FLAGS = {
|
|||
context_evaluator=None,
|
||||
),
|
||||
"context-aware": InMemoryFlag(
|
||||
flag_key="context-aware",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
variants={"internal": "INTERNAL", "external": "EXTERNAL"},
|
||||
default_variant="external",
|
||||
context_evaluator=context_func,
|
||||
),
|
||||
"wrong-flag": InMemoryFlag(
|
||||
flag_key="wrong-flag",
|
||||
state="ENABLED",
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
variants={"one": "uno", "two": "dos"},
|
||||
default_variant="one",
|
||||
),
|
||||
"metadata-flag": InMemoryFlag(
|
||||
state=InMemoryFlag.State.ENABLED,
|
||||
default_variant="on",
|
||||
variants={"on": True, "off": False},
|
||||
context_evaluator=None,
|
||||
flag_metadata={
|
||||
"string": "1.0.2",
|
||||
"integer": 2,
|
||||
"float": 0.1,
|
||||
"boolean": True,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import contextlib
|
||||
|
||||
from behave import given, when
|
||||
|
||||
|
||||
@given('a {flag_type}-flag with key "{flag_key}" and a default value "{default_value}"')
|
||||
def step_impl_flag(context, flag_type: str, flag_key, default_value):
|
||||
if default_value.lower() == "true" or default_value.lower() == "false":
|
||||
default_value = bool(default_value)
|
||||
try:
|
||||
default_value = int(default_value)
|
||||
except ValueError:
|
||||
with contextlib.suppress(ValueError):
|
||||
default_value = float(default_value)
|
||||
context.flag = (flag_type, flag_key, default_value)
|
||||
|
||||
|
||||
@when("the flag was evaluated with details")
|
||||
def step_impl_evaluation(context):
|
||||
client = context.client
|
||||
flag_type, key, default_value = context.flag
|
||||
if flag_type.lower() == "string":
|
||||
context.evaluation = client.get_string_details(key, default_value)
|
||||
elif flag_type.lower() == "boolean":
|
||||
context.evaluation = client.get_boolean_details(key, default_value)
|
||||
elif flag_type.lower() == "object":
|
||||
context.evaluation = client.get_object_details(key, default_value)
|
||||
elif flag_type.lower() == "float":
|
||||
context.evaluation = client.get_float_details(key, default_value)
|
||||
elif flag_type.lower() == "integer":
|
||||
context.evaluation = client.get_integer_details(key, default_value)
|
|
@ -0,0 +1,66 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then
|
||||
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import Reason
|
||||
from openfeature.hook import Hook
|
||||
|
||||
|
||||
@given("a client with added hook")
|
||||
def step_impl_add_hook(context):
|
||||
hook = MagicMock(spec=Hook)
|
||||
hook.before = MagicMock()
|
||||
hook.after = MagicMock()
|
||||
hook.error = MagicMock()
|
||||
hook.finally_after = MagicMock()
|
||||
context.hook = hook
|
||||
context.client.add_hooks([hook])
|
||||
|
||||
|
||||
@then('the "{hook_name}" hook should have been executed')
|
||||
def step_impl_should_called(context, hook_name):
|
||||
hook = get_hook_from_name(context, hook_name)
|
||||
assert hook.called
|
||||
|
||||
|
||||
@then('the "{hook_names}" hooks should be called with evaluation details')
|
||||
def step_impl_should_have_eval_details(context, hook_names):
|
||||
for hook_name in hook_names.split(", "):
|
||||
hook = get_hook_from_name(context, hook_name)
|
||||
for row in context.table:
|
||||
flag_type, key, value = row
|
||||
|
||||
value = convert_value_from_key_and_flag_type(value, key, flag_type)
|
||||
actual = hook.call_args[1]["details"].__dict__[key]
|
||||
|
||||
assert actual == value
|
||||
|
||||
|
||||
def get_hook_from_name(context, hook_name):
|
||||
if hook_name.lower() == "before":
|
||||
return context.hook.before
|
||||
elif hook_name.lower() == "after":
|
||||
return context.hook.after
|
||||
elif hook_name.lower() == "error":
|
||||
return context.hook.error
|
||||
elif hook_name.lower() == "finally":
|
||||
return context.hook.finally_after
|
||||
else:
|
||||
raise ValueError(str(hook_name) + " is not a valid hook name")
|
||||
|
||||
|
||||
def convert_value_from_key_and_flag_type(value, key, flag_type):
|
||||
if value in ("None", "null"):
|
||||
return None
|
||||
if flag_type.lower() == "boolean":
|
||||
return bool(value)
|
||||
elif flag_type.lower() == "integer":
|
||||
return int(value)
|
||||
elif flag_type.lower() == "float":
|
||||
return float(value)
|
||||
elif key == "reason":
|
||||
return Reason(value)
|
||||
elif key == "error_code":
|
||||
return ErrorCode(value)
|
||||
return value
|
|
@ -0,0 +1,43 @@
|
|||
from behave import given, then
|
||||
|
||||
from openfeature.api import get_client, set_provider
|
||||
from openfeature.provider.in_memory_provider import InMemoryProvider
|
||||
from tests.features.data import IN_MEMORY_FLAGS
|
||||
|
||||
|
||||
@given("a stable provider")
|
||||
def step_impl_stable_provider(context):
|
||||
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
|
||||
context.client = get_client()
|
||||
|
||||
|
||||
@then('the resolved metadata value "{key}" should be "{value}"')
|
||||
def step_impl_check_metadata(context, key, value):
|
||||
assert context.evaluation.flag_metadata[key] == value
|
||||
|
||||
|
||||
@then("the resolved metadata is empty")
|
||||
def step_impl_empty_metadata(context):
|
||||
assert not context.evaluation.flag_metadata
|
||||
|
||||
|
||||
@then("the resolved metadata should contain")
|
||||
def step_impl_metadata_contains(context):
|
||||
for row in context.table:
|
||||
key, metadata_type, value = row
|
||||
|
||||
assert context.evaluation.flag_metadata[
|
||||
key
|
||||
] == convert_value_from_metadata_type(value, metadata_type)
|
||||
|
||||
|
||||
def convert_value_from_metadata_type(value, metadata_type):
|
||||
if value == "None":
|
||||
return None
|
||||
if metadata_type.lower() == "boolean":
|
||||
return bool(value)
|
||||
elif metadata_type.lower() == "integer":
|
||||
return int(value)
|
||||
elif metadata_type.lower() == "float":
|
||||
return float(value)
|
||||
return value
|
|
@ -1,13 +1,15 @@
|
|||
# flake8: noqa: F811
|
||||
|
||||
from time import sleep
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from open_feature.api import get_client, set_provider
|
||||
from open_feature.client import OpenFeatureClient
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.exception import ErrorCode
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, Reason
|
||||
from open_feature.provider.in_memory_provider import InMemoryProvider
|
||||
from openfeature.api import get_client, set_provider
|
||||
from openfeature.client import OpenFeatureClient
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
|
||||
from openfeature.provider.in_memory_provider import InMemoryProvider
|
||||
from tests.features.data import IN_MEMORY_FLAGS
|
||||
|
||||
# Common step definitions
|
||||
|
@ -17,7 +19,7 @@ from tests.features.data import IN_MEMORY_FLAGS
|
|||
'the resolved {flag_type} details reason of flag with key "{key}" should be '
|
||||
'"{reason}"'
|
||||
)
|
||||
def step_impl(context, flag_type, key, expected_reason):
|
||||
def step_impl_resolved_should_be(context, flag_type, key, expected_reason):
|
||||
details: FlagEvaluationDetails = None
|
||||
if flag_type == "boolean":
|
||||
details = context.boolean_flag_details
|
||||
|
@ -25,17 +27,24 @@ def step_impl(context, flag_type, key, expected_reason):
|
|||
|
||||
|
||||
@given("a provider is registered with cache disabled")
|
||||
def step_impl(context):
|
||||
def step_impl_provider_without_cache(context):
|
||||
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
|
||||
context.client = get_client(name="Default Provider", version="1.0")
|
||||
context.client = get_client()
|
||||
|
||||
|
||||
@given("a provider is registered")
|
||||
def step_impl_provider(context):
|
||||
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
|
||||
context.client = get_client()
|
||||
|
||||
|
||||
@when(
|
||||
'a {flag_type} flag with key "{key}" is evaluated with details and default value '
|
||||
'"{default_value}"'
|
||||
)
|
||||
def step_impl(context, flag_type, key, default_value):
|
||||
context.client = get_client(name="Default Provider", version="1.0")
|
||||
def step_impl_evaluated_with_details(context, flag_type, key, default_value):
|
||||
if context.client is None:
|
||||
context.client = get_client()
|
||||
if flag_type == "boolean":
|
||||
context.boolean_flag_details = context.client.get_boolean_details(
|
||||
key, default_value
|
||||
|
@ -50,7 +59,9 @@ def step_impl(context, flag_type, key, default_value):
|
|||
'a boolean flag with key "{key}" is evaluated with {eval_details} and default '
|
||||
'value "{default_value}"'
|
||||
)
|
||||
def step_impl(context, key, eval_details, default_value):
|
||||
def step_impl_bool_evaluated_with_details_and_default(
|
||||
context, key, eval_details, default_value
|
||||
):
|
||||
client: OpenFeatureClient = context.client
|
||||
|
||||
context.boolean_flag_details = client.get_boolean_details(key, default_value)
|
||||
|
@ -60,7 +71,7 @@ def step_impl(context, key, eval_details, default_value):
|
|||
'a {flag_type} flag with key "{key}" is evaluated with default value '
|
||||
'"{default_value}"'
|
||||
)
|
||||
def step_impl(context, flag_type, key, default_value):
|
||||
def step_impl_evaluated_with_default(context, flag_type, key, default_value):
|
||||
client: OpenFeatureClient = context.client
|
||||
|
||||
if flag_type == "boolean":
|
||||
|
@ -70,12 +81,12 @@ def step_impl(context, flag_type, key, default_value):
|
|||
|
||||
|
||||
@then('the resolved string value should be "{expected_value}"')
|
||||
def step_impl(context, expected_value):
|
||||
def step_impl_resolved_string_should_be(context, expected_value):
|
||||
assert expected_value == context.string_flag_details.value
|
||||
|
||||
|
||||
@then('the resolved boolean value should be "{expected_value}"')
|
||||
def step_impl(context, expected_value):
|
||||
def step_impl_resolved_bool_should_be(context, expected_value):
|
||||
assert parse_boolean(expected_value) == context.boolean_flag_details.value
|
||||
|
||||
|
||||
|
@ -83,7 +94,7 @@ def step_impl(context, expected_value):
|
|||
'an integer flag with key "{key}" is evaluated with details and default value '
|
||||
"{default_value:d}"
|
||||
)
|
||||
def step_impl(context, key, default_value):
|
||||
def step_impl_int_evaluated_with_details_and_default(context, key, default_value):
|
||||
context.flag_key = key
|
||||
context.default_value = default_value
|
||||
context.integer_flag_details = context.client.get_integer_details(
|
||||
|
@ -94,7 +105,7 @@ def step_impl(context, key, default_value):
|
|||
@when(
|
||||
'an integer flag with key "{key}" is evaluated with default value {default_value:d}'
|
||||
)
|
||||
def step_impl(context, key, default_value):
|
||||
def step_impl_int_evaluated_with_default(context, key, default_value):
|
||||
context.flag_key = key
|
||||
context.default_value = default_value
|
||||
context.integer_flag_details = context.client.get_integer_details(
|
||||
|
@ -103,26 +114,26 @@ def step_impl(context, key, default_value):
|
|||
|
||||
|
||||
@when('a float flag with key "{key}" is evaluated with default value {default_value:f}')
|
||||
def step_impl(context, key, default_value):
|
||||
def step_impl_float_evaluated_with_default(context, key, default_value):
|
||||
context.flag_key = key
|
||||
context.default_value = default_value
|
||||
context.float_flag_details = context.client.get_float_details(key, default_value)
|
||||
|
||||
|
||||
@when('an object flag with key "{key}" is evaluated with a null default value')
|
||||
def step_impl(context, key):
|
||||
def step_impl_obj_evaluated_with_default(context, key):
|
||||
context.flag_key = key
|
||||
context.default_value = None
|
||||
context.object_flag_details = context.client.get_object_details(key, None)
|
||||
|
||||
|
||||
@then("the resolved integer value should be {expected_value:d}")
|
||||
def step_impl(context, expected_value):
|
||||
def step_impl_resolved_int_should_be(context, expected_value):
|
||||
assert expected_value == context.integer_flag_details.value
|
||||
|
||||
|
||||
@then("the resolved float value should be {expected_value:f}")
|
||||
def step_impl(context, expected_value):
|
||||
def step_impl_resolved_bool_should_be(context, expected_value):
|
||||
assert expected_value == context.float_flag_details.value
|
||||
|
||||
|
||||
|
@ -131,7 +142,9 @@ def step_impl(context, expected_value):
|
|||
'the resolved boolean details value should be "{expected_value}", the variant '
|
||||
'should be "{variant}", and the reason should be "{reason}"'
|
||||
)
|
||||
def step_impl(context, expected_value, variant, reason):
|
||||
def step_impl_resolved_bool_should_be_with_reason(
|
||||
context, expected_value, variant, reason
|
||||
):
|
||||
assert parse_boolean(expected_value) == context.boolean_flag_details.value
|
||||
assert variant == context.boolean_flag_details.variant
|
||||
assert reason == context.boolean_flag_details.reason
|
||||
|
@ -141,7 +154,9 @@ def step_impl(context, expected_value, variant, reason):
|
|||
'the resolved string details value should be "{expected_value}", the variant '
|
||||
'should be "{variant}", and the reason should be "{reason}"'
|
||||
)
|
||||
def step_impl(context, expected_value, variant, reason):
|
||||
def step_impl_resolved_string_should_be_with_reason(
|
||||
context, expected_value, variant, reason
|
||||
):
|
||||
assert expected_value == context.string_flag_details.value
|
||||
assert variant == context.string_flag_details.variant
|
||||
assert reason == context.string_flag_details.reason
|
||||
|
@ -151,7 +166,9 @@ def step_impl(context, expected_value, variant, reason):
|
|||
'the resolved object value should be contain fields "{field1}", "{field2}", and '
|
||||
'"{field3}", with values "{val1}", "{val2}" and {val3}, respectively'
|
||||
)
|
||||
def step_impl(context, field1, field2, field3, val1, val2, val3):
|
||||
def step_impl_resolved_obj_should_contain(
|
||||
context, field1, field2, field3, val1, val2, val3
|
||||
):
|
||||
value = context.object_flag_details.value
|
||||
assert field1 in value
|
||||
assert field2 in value
|
||||
|
@ -162,7 +179,7 @@ def step_impl(context, field1, field2, field3, val1, val2, val3):
|
|||
|
||||
|
||||
@then('the resolved flag value is "{flag_value}" when the context is empty')
|
||||
def step_impl(context, flag_value):
|
||||
def step_impl_resolved_is_with_empty_context(context, flag_value):
|
||||
context.string_flag_details = context.client.get_boolean_details(
|
||||
context.flag_key, context.default_value
|
||||
)
|
||||
|
@ -173,13 +190,13 @@ def step_impl(context, flag_value):
|
|||
"the reason should indicate an error and the error code should indicate a missing "
|
||||
'flag with "{error_code}"'
|
||||
)
|
||||
def step_impl(context, error_code):
|
||||
def step_impl_reason_should_indicate(context, error_code):
|
||||
assert context.string_flag_details.reason == Reason.ERROR
|
||||
assert context.string_flag_details.error_code == ErrorCode[error_code]
|
||||
|
||||
|
||||
@then("the default {flag_type} value should be returned")
|
||||
def step_impl(context, flag_type):
|
||||
def step_impl_return_default(context, flag_type):
|
||||
flag_details = getattr(context, f"{flag_type}_flag_details")
|
||||
assert context.default_value == flag_details.value
|
||||
|
||||
|
@ -188,7 +205,7 @@ def step_impl(context, flag_type):
|
|||
'a float flag with key "{key}" is evaluated with details and default value '
|
||||
"{default_value:f}"
|
||||
)
|
||||
def step_impl(context, key, default_value):
|
||||
def step_impl_float_with_details(context, key, default_value):
|
||||
context.float_flag_details = context.client.get_float_details(key, default_value)
|
||||
|
||||
|
||||
|
@ -196,7 +213,7 @@ def step_impl(context, key, default_value):
|
|||
"the resolved float details value should be {expected_value:f}, the variant should "
|
||||
'be "{variant}", and the reason should be "{reason}"'
|
||||
)
|
||||
def step_impl(context, expected_value, variant, reason):
|
||||
def step_impl_resolved_float_with_variant(context, expected_value, variant, reason):
|
||||
assert expected_value == context.float_flag_details.value
|
||||
assert variant == context.float_flag_details.variant
|
||||
assert reason == context.float_flag_details.reason
|
||||
|
@ -205,7 +222,7 @@ def step_impl(context, expected_value, variant, reason):
|
|||
@when(
|
||||
'an object flag with key "{key}" is evaluated with details and a null default value'
|
||||
)
|
||||
def step_impl(context, key):
|
||||
def step_impl_eval_obj(context, key):
|
||||
context.object_flag_details = context.client.get_object_details(key, None)
|
||||
|
||||
|
||||
|
@ -213,7 +230,7 @@ def step_impl(context, key):
|
|||
'the resolved object details value should be contain fields "{field1}", "{field2}",'
|
||||
' and "{field3}", with values "{val1}", "{val2}" and {val3}, respectively'
|
||||
)
|
||||
def step_impl(context, field1, field2, field3, val1, val2, val3):
|
||||
def step_impl_eval_obj_with_fields(context, field1, field2, field3, val1, val2, val3):
|
||||
value = context.object_flag_details.value
|
||||
assert field1 in value
|
||||
assert field2 in value
|
||||
|
@ -224,7 +241,7 @@ def step_impl(context, field1, field2, field3, val1, val2, val3):
|
|||
|
||||
|
||||
@then('the variant should be "{variant}", and the reason should be "{reason}"')
|
||||
def step_impl(context, variant, reason):
|
||||
def step_impl_variant(context, variant, reason):
|
||||
assert variant == context.object_flag_details.variant
|
||||
assert reason == context.object_flag_details.reason
|
||||
|
||||
|
@ -233,7 +250,7 @@ def step_impl(context, variant, reason):
|
|||
'context contains keys "{key1}", "{key2}", "{key3}", "{key4}" with values "{val1}",'
|
||||
' "{val2}", {val3:d}, "{val4}"'
|
||||
)
|
||||
def step_impl(context, key1, key2, key3, key4, val1, val2, val3, val4):
|
||||
def step_impl_context(context, key1, key2, key3, key4, val1, val2, val3, val4):
|
||||
context.evaluation_context = EvaluationContext(
|
||||
None,
|
||||
{
|
||||
|
@ -246,7 +263,7 @@ def step_impl(context, key1, key2, key3, key4, val1, val2, val3, val4):
|
|||
|
||||
|
||||
@when('a flag with key "{key}" is evaluated with default value "{default_value}"')
|
||||
def step_impl(context, key, default_value):
|
||||
def step_impl_flag_with_key_and_default(context, key, default_value):
|
||||
context.flag_key = key
|
||||
context.default_value = default_value
|
||||
context.string_flag_details = context.client.get_string_details(
|
||||
|
@ -255,7 +272,7 @@ def step_impl(context, key, default_value):
|
|||
|
||||
|
||||
@then('the resolved string response should be "{expected_value}"')
|
||||
def step_impl(context, expected_value):
|
||||
def step_impl_reason(context, expected_value):
|
||||
assert expected_value == context.string_flag_details.value
|
||||
|
||||
|
||||
|
@ -263,7 +280,7 @@ def step_impl(context, expected_value):
|
|||
'a non-existent string flag with key "{flag_key}" is evaluated with details and a '
|
||||
'default value "{default_value}"'
|
||||
)
|
||||
def step_impl(context, flag_key, default_value):
|
||||
def step_impl_non_existing(context, flag_key, default_value):
|
||||
context.flag_key = flag_key
|
||||
context.default_value = default_value
|
||||
context.string_flag_details = context.client.get_string_details(
|
||||
|
@ -275,7 +292,7 @@ def step_impl(context, flag_key, default_value):
|
|||
'a string flag with key "{flag_key}" is evaluated as an integer, with details and a'
|
||||
" default value {default_value:d}"
|
||||
)
|
||||
def step_impl(context, flag_key, default_value):
|
||||
def step_impl_string_with_details(context, flag_key, default_value):
|
||||
context.flag_key = flag_key
|
||||
context.default_value = default_value
|
||||
context.integer_flag_details = context.client.get_integer_details(
|
||||
|
@ -287,7 +304,7 @@ def step_impl(context, flag_key, default_value):
|
|||
"the reason should indicate an error and the error code should indicate a type "
|
||||
'mismatch with "{error_code}"'
|
||||
)
|
||||
def step_impl(context, error_code):
|
||||
def step_impl_type_mismatch(context, error_code):
|
||||
assert context.integer_flag_details.reason == Reason.ERROR
|
||||
assert context.integer_flag_details.error_code == ErrorCode[error_code]
|
||||
|
||||
|
@ -299,17 +316,17 @@ def step_impl(context, error_code):
|
|||
'the flag\'s configuration with key "{key}" is updated to defaultVariant '
|
||||
'"{variant}"'
|
||||
)
|
||||
def step_impl(context, key, variant):
|
||||
def step_impl_config_update(context, key, variant):
|
||||
raise NotImplementedError("Step definition not implemented yet")
|
||||
|
||||
|
||||
@given("sleep for {duration} milliseconds")
|
||||
def step_impl(context, duration):
|
||||
raise NotImplementedError("Step definition not implemented yet")
|
||||
def step_impl_sleep(context, duration):
|
||||
sleep(float(duration) * 0.001)
|
||||
|
||||
|
||||
@then('the resolved string details reason should be "{reason}"')
|
||||
def step_impl(context, reason):
|
||||
def step_impl_reason_should_be(context, reason):
|
||||
raise NotImplementedError("Step definition not implemented yet")
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ from unittest import mock
|
|||
|
||||
import pytest
|
||||
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
|
@ -1,20 +1,81 @@
|
|||
from unittest.mock import ANY
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
from open_feature.hook import HookContext
|
||||
from open_feature.hook.hook_support import (
|
||||
import pytest
|
||||
|
||||
from openfeature.client import ClientMetadata
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType
|
||||
from openfeature.hook import Hook, HookContext
|
||||
from openfeature.hook._hook_support import (
|
||||
after_all_hooks,
|
||||
after_hooks,
|
||||
before_hooks,
|
||||
error_hooks,
|
||||
)
|
||||
from open_feature.immutable_dict.mapping_proxy_type import MappingProxyType
|
||||
from openfeature.immutable_dict.mapping_proxy_type import MappingProxyType
|
||||
from openfeature.provider.metadata import Metadata
|
||||
|
||||
|
||||
def test_hook_context_has_required_and_optional_fields():
|
||||
"""Requirement
|
||||
|
||||
4.1.1 - Hook context MUST provide: the "flag key", "flag value type", "evaluation context", and the "default value".
|
||||
4.1.2 - The "hook context" SHOULD provide: access to the "client metadata" and the "provider metadata" fields.
|
||||
"""
|
||||
|
||||
# Given/When
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
|
||||
|
||||
# Then
|
||||
assert hasattr(hook_context, "flag_key")
|
||||
assert hasattr(hook_context, "flag_type")
|
||||
assert hasattr(hook_context, "default_value")
|
||||
assert hasattr(hook_context, "evaluation_context")
|
||||
assert hasattr(hook_context, "client_metadata")
|
||||
assert hasattr(hook_context, "provider_metadata")
|
||||
|
||||
|
||||
def test_hook_context_has_immutable_and_mutable_fields():
|
||||
"""Requirement
|
||||
|
||||
4.1.3 - The "flag key", "flag type", and "default value" properties MUST be immutable.
|
||||
4.1.4.1 - The evaluation context MUST be mutable only within the before hook.
|
||||
4.2.2.2 - The client "metadata" field in the "hook context" MUST be immutable.
|
||||
4.2.2.3 - The provider "metadata" field in the "hook context" MUST be immutable.
|
||||
"""
|
||||
|
||||
# Given
|
||||
hook_context = HookContext(
|
||||
"flag_key", FlagType.BOOLEAN, True, EvaluationContext(), ClientMetadata("name")
|
||||
)
|
||||
|
||||
# When
|
||||
with pytest.raises(AttributeError):
|
||||
hook_context.flag_key = "new_key"
|
||||
with pytest.raises(AttributeError):
|
||||
hook_context.flag_type = FlagType.STRING
|
||||
with pytest.raises(AttributeError):
|
||||
hook_context.default_value = "new_value"
|
||||
with pytest.raises(AttributeError):
|
||||
hook_context.client_metadata = ClientMetadata("new_name")
|
||||
with pytest.raises(AttributeError):
|
||||
hook_context.provider_metadata = Metadata("name")
|
||||
|
||||
hook_context.evaluation_context = EvaluationContext("targeting_key")
|
||||
|
||||
# Then
|
||||
assert hook_context.flag_key == "flag_key"
|
||||
assert hook_context.flag_type is FlagType.BOOLEAN
|
||||
assert hook_context.default_value is True
|
||||
assert hook_context.evaluation_context.targeting_key == "targeting_key"
|
||||
assert hook_context.client_metadata.name == "name"
|
||||
assert hook_context.provider_metadata is None
|
||||
|
||||
|
||||
def test_error_hooks_run_error_method(mock_hook):
|
||||
# Given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
|
||||
hook_hints = MappingProxyType(dict())
|
||||
hook_hints = MappingProxyType({})
|
||||
# When
|
||||
error_hooks(FlagType.BOOLEAN, hook_context, Exception, [mock_hook], hook_hints)
|
||||
# Then
|
||||
|
@ -28,7 +89,7 @@ def test_error_hooks_run_error_method(mock_hook):
|
|||
def test_before_hooks_run_before_method(mock_hook):
|
||||
# Given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
|
||||
hook_hints = MappingProxyType(dict())
|
||||
hook_hints = MappingProxyType({})
|
||||
# When
|
||||
before_hooks(FlagType.BOOLEAN, hook_context, [mock_hook], hook_hints)
|
||||
# Then
|
||||
|
@ -37,13 +98,30 @@ def test_before_hooks_run_before_method(mock_hook):
|
|||
mock_hook.before.assert_called_with(hook_context=hook_context, hints=hook_hints)
|
||||
|
||||
|
||||
def test_before_hooks_merges_evaluation_contexts():
|
||||
# Given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
|
||||
hook_1 = MagicMock(spec=Hook)
|
||||
hook_1.before.return_value = EvaluationContext("foo", {"key_1": "val_1"})
|
||||
hook_2 = MagicMock(spec=Hook)
|
||||
hook_2.before.return_value = EvaluationContext("bar", {"key_2": "val_2"})
|
||||
hook_3 = MagicMock(spec=Hook)
|
||||
hook_3.before.return_value = None
|
||||
|
||||
# When
|
||||
context = before_hooks(FlagType.BOOLEAN, hook_context, [hook_1, hook_2, hook_3])
|
||||
|
||||
# Then
|
||||
assert context == EvaluationContext("bar", {"key_1": "val_1", "key_2": "val_2"})
|
||||
|
||||
|
||||
def test_after_hooks_run_after_method(mock_hook):
|
||||
# Given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
|
||||
flag_evaluation_details = FlagEvaluationDetails(
|
||||
hook_context.flag_key, "val", "unknown"
|
||||
)
|
||||
hook_hints = MappingProxyType(dict())
|
||||
hook_hints = MappingProxyType({})
|
||||
# When
|
||||
after_hooks(
|
||||
FlagType.BOOLEAN, hook_context, flag_evaluation_details, [mock_hook], hook_hints
|
||||
|
@ -59,12 +137,17 @@ def test_after_hooks_run_after_method(mock_hook):
|
|||
def test_finally_after_hooks_run_finally_after_method(mock_hook):
|
||||
# Given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
|
||||
hook_hints = MappingProxyType(dict())
|
||||
flag_evaluation_details = FlagEvaluationDetails(
|
||||
hook_context.flag_key, "val", "unknown"
|
||||
)
|
||||
hook_hints = MappingProxyType({})
|
||||
# When
|
||||
after_all_hooks(FlagType.BOOLEAN, hook_context, [mock_hook], hook_hints)
|
||||
after_all_hooks(
|
||||
FlagType.BOOLEAN, hook_context, flag_evaluation_details, [mock_hook], hook_hints
|
||||
)
|
||||
# Then
|
||||
mock_hook.supports_flag_value_type.assert_called_once()
|
||||
mock_hook.finally_after.assert_called_once()
|
||||
mock_hook.finally_after.assert_called_with(
|
||||
hook_context=hook_context, hints=hook_hints
|
||||
hook_context=hook_context, details=flag_evaluation_details, hints=hook_hints
|
||||
)
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
from numbers import Number
|
||||
|
||||
from open_feature.exception import ErrorCode
|
||||
from open_feature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from open_feature.provider.in_memory_provider import InMemoryProvider, InMemoryFlag
|
||||
import pytest
|
||||
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
||||
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
|
||||
|
||||
|
||||
def test_should_return_in_memory_provider_metadata():
|
||||
|
@ -15,21 +17,27 @@ def test_should_return_in_memory_provider_metadata():
|
|||
assert metadata.name == "In-Memory Provider"
|
||||
|
||||
|
||||
def test_should_handle_unknown_flags_correctly():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_unknown_flags_correctly():
|
||||
# Given
|
||||
provider = InMemoryProvider({})
|
||||
# When
|
||||
flag = provider.resolve_boolean_details(flag_key="Key", default_value=True)
|
||||
flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=True)
|
||||
flag_async = await provider.resolve_boolean_details_async(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value is True
|
||||
assert isinstance(flag.value, bool)
|
||||
assert flag.reason == Reason.ERROR
|
||||
assert flag.error_code == ErrorCode.FLAG_NOT_FOUND
|
||||
assert flag.error_message == "Flag 'Key' not found"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value is True
|
||||
assert flag.reason == Reason.ERROR
|
||||
assert flag.error_code == ErrorCode.FLAG_NOT_FOUND
|
||||
assert flag.error_message == "Flag 'Key' not found"
|
||||
|
||||
|
||||
def test_calls_context_evaluator_if_present():
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_context_evaluator_if_present():
|
||||
# Given
|
||||
def context_evaluator(flag: InMemoryFlag, evaluation_context: dict):
|
||||
return FlagResolutionDetails(
|
||||
|
@ -40,7 +48,6 @@ def test_calls_context_evaluator_if_present():
|
|||
provider = InMemoryProvider(
|
||||
{
|
||||
"Key": InMemoryFlag(
|
||||
"Key",
|
||||
"true",
|
||||
{"true": True, "false": False},
|
||||
context_evaluator=context_evaluator,
|
||||
|
@ -48,95 +55,126 @@ def test_calls_context_evaluator_if_present():
|
|||
}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_boolean_details(flag_key="Key", default_value=False)
|
||||
flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False)
|
||||
flag_async = await provider.resolve_boolean_details_async(
|
||||
flag_key="Key", default_value=False
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value is False
|
||||
assert isinstance(flag.value, bool)
|
||||
assert flag.reason == Reason.TARGETING_MATCH
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value is False
|
||||
assert isinstance(flag.value, bool)
|
||||
assert flag.reason == Reason.TARGETING_MATCH
|
||||
|
||||
|
||||
def test_should_resolve_boolean_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_boolean_flag_from_in_memory():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{"Key": InMemoryFlag("Key", "true", {"true": True, "false": False})}
|
||||
{"Key": InMemoryFlag("true", {"true": True, "false": False})}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_boolean_details(flag_key="Key", default_value=False)
|
||||
flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False)
|
||||
flag_async = await provider.resolve_boolean_details_async(
|
||||
flag_key="Key", default_value=False
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value is True
|
||||
assert isinstance(flag.value, bool)
|
||||
assert flag.variant == "true"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value is True
|
||||
assert isinstance(flag.value, bool)
|
||||
assert flag.variant == "true"
|
||||
|
||||
|
||||
def test_should_resolve_integer_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_integer_flag_from_in_memory():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{"Key": InMemoryFlag("Key", "hundred", {"zero": 0, "hundred": 100})}
|
||||
{"Key": InMemoryFlag("hundred", {"zero": 0, "hundred": 100})}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_integer_details(flag_key="Key", default_value=0)
|
||||
flag_sync = provider.resolve_integer_details(flag_key="Key", default_value=0)
|
||||
flag_async = await provider.resolve_integer_details_async(
|
||||
flag_key="Key", default_value=0
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == 100
|
||||
assert isinstance(flag.value, Number)
|
||||
assert flag.variant == "hundred"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value == 100
|
||||
assert isinstance(flag.value, Number)
|
||||
assert flag.variant == "hundred"
|
||||
|
||||
|
||||
def test_should_resolve_float_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_float_flag_from_in_memory():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{"Key": InMemoryFlag("Key", "ten", {"zero": 0.0, "ten": 10.23})}
|
||||
{"Key": InMemoryFlag("ten", {"zero": 0.0, "ten": 10.23})}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_float_details(flag_key="Key", default_value=0.0)
|
||||
flag_sync = provider.resolve_float_details(flag_key="Key", default_value=0.0)
|
||||
flag_async = await provider.resolve_float_details_async(
|
||||
flag_key="Key", default_value=0.0
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == 10.23
|
||||
assert isinstance(flag.value, Number)
|
||||
assert flag.variant == "ten"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value == 10.23
|
||||
assert isinstance(flag.value, Number)
|
||||
assert flag.variant == "ten"
|
||||
|
||||
|
||||
def test_should_resolve_string_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_string_flag_from_in_memory():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{
|
||||
"Key": InMemoryFlag(
|
||||
"Key",
|
||||
"stringVariant",
|
||||
{"defaultVariant": "Default", "stringVariant": "String"},
|
||||
)
|
||||
}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_string_details(flag_key="Key", default_value="Default")
|
||||
flag_sync = provider.resolve_string_details(flag_key="Key", default_value="Default")
|
||||
flag_async = await provider.resolve_string_details_async(
|
||||
flag_key="Key", default_value="Default"
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == "String"
|
||||
assert isinstance(flag.value, str)
|
||||
assert flag.variant == "stringVariant"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value == "String"
|
||||
assert isinstance(flag.value, str)
|
||||
assert flag.variant == "stringVariant"
|
||||
|
||||
|
||||
def test_should_resolve_list_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_list_flag_from_in_memory():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{
|
||||
"Key": InMemoryFlag(
|
||||
"Key", "twoItems", {"empty": [], "twoItems": ["item1", "item2"]}
|
||||
)
|
||||
}
|
||||
{"Key": InMemoryFlag("twoItems", {"empty": [], "twoItems": ["item1", "item2"]})}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_object_details(flag_key="Key", default_value=[])
|
||||
flag_sync = provider.resolve_object_details(flag_key="Key", default_value=[])
|
||||
flag_async = await provider.resolve_object_details_async(
|
||||
flag_key="Key", default_value=[]
|
||||
)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == ["item1", "item2"]
|
||||
assert isinstance(flag.value, list)
|
||||
assert flag.variant == "twoItems"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value == ["item1", "item2"]
|
||||
assert isinstance(flag.value, list)
|
||||
assert flag.variant == "twoItems"
|
||||
|
||||
|
||||
def test_should_resolve_object_flag_from_in_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_resolve_object_flag_from_in_memory():
|
||||
# Given
|
||||
return_value = {
|
||||
"String": "string",
|
||||
|
@ -144,12 +182,15 @@ def test_should_resolve_object_flag_from_in_memory():
|
|||
"Boolean": True,
|
||||
}
|
||||
provider = InMemoryProvider(
|
||||
{"Key": InMemoryFlag("Key", "obj", {"obj": return_value, "empty": {}})}
|
||||
{"Key": InMemoryFlag("obj", {"obj": return_value, "empty": {}})}
|
||||
)
|
||||
# When
|
||||
flag = provider.resolve_object_details(flag_key="Key", default_value={})
|
||||
flag_sync = provider.resolve_object_details(flag_key="Key", default_value={})
|
||||
flag_async = provider.resolve_object_details(flag_key="Key", default_value={})
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == return_value
|
||||
assert isinstance(flag.value, dict)
|
||||
assert flag.variant == "obj"
|
||||
assert flag_sync == flag_async
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag is not None
|
||||
assert flag.value == return_value
|
||||
assert isinstance(flag.value, dict)
|
||||
assert flag.variant == "obj"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from numbers import Number
|
||||
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
|
||||
def test_should_return_no_op_provider_metadata():
|
||||
|
|
|
@ -0,0 +1,197 @@
|
|||
from typing import Optional, Union
|
||||
|
||||
import pytest
|
||||
|
||||
from openfeature.api import get_client, set_provider
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails
|
||||
from openfeature.provider import AbstractProvider, Metadata
|
||||
|
||||
|
||||
class SynchronousProvider(AbstractProvider):
|
||||
def get_metadata(self):
|
||||
return Metadata(name="SynchronousProvider")
|
||||
|
||||
def get_provider_hooks(self):
|
||||
return []
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return FlagResolutionDetails(value=True)
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
return FlagResolutionDetails(value="string")
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
return FlagResolutionDetails(value=1)
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
return FlagResolutionDetails(value=10.0)
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: Union[dict, list],
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[Union[dict, list]]:
|
||||
return FlagResolutionDetails(value={"key": "value"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag_type, default_value, get_method",
|
||||
(
|
||||
(bool, True, "get_boolean_value_async"),
|
||||
(str, "string", "get_string_value_async"),
|
||||
(int, 1, "get_integer_value_async"),
|
||||
(float, 10.0, "get_float_value_async"),
|
||||
(
|
||||
dict,
|
||||
{"key": "value"},
|
||||
"get_object_value_async",
|
||||
),
|
||||
),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_provider_can_be_called_async(flag_type, default_value, get_method):
|
||||
# Given
|
||||
set_provider(SynchronousProvider(), "SynchronousProvider")
|
||||
client = get_client("SynchronousProvider")
|
||||
# When
|
||||
async_callable = getattr(client, get_method)
|
||||
flag = await async_callable(flag_key="Key", default_value=default_value)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag == default_value
|
||||
assert isinstance(flag, flag_type)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_provider_can_be_extended_async():
|
||||
# Given
|
||||
class ExtendedAsyncProvider(SynchronousProvider):
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return FlagResolutionDetails(value=False)
|
||||
|
||||
set_provider(ExtendedAsyncProvider(), "ExtendedAsyncProvider")
|
||||
client = get_client("ExtendedAsyncProvider")
|
||||
# When
|
||||
flag = await client.get_boolean_value_async(flag_key="Key", default_value=True)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag is False
|
||||
|
||||
|
||||
# We're not allowing providers to only have async methods
|
||||
def test_sync_methods_enforced_for_async_providers():
|
||||
# Given
|
||||
class AsyncProvider(AbstractProvider):
|
||||
def get_metadata(self):
|
||||
return Metadata(name="AsyncProvider")
|
||||
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return FlagResolutionDetails(value=True)
|
||||
|
||||
# When
|
||||
with pytest.raises(TypeError) as exception:
|
||||
set_provider(AsyncProvider(), "AsyncProvider")
|
||||
|
||||
# Then
|
||||
# assert
|
||||
exception_message = str(exception.value)
|
||||
assert exception_message.startswith(
|
||||
"Can't instantiate abstract class AsyncProvider"
|
||||
)
|
||||
assert exception_message.__contains__("resolve_boolean_details")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_provider_not_implemented_exception_workaround():
|
||||
# Given
|
||||
class SyncNotImplementedProvider(AbstractProvider):
|
||||
def get_metadata(self):
|
||||
return Metadata(name="AsyncProvider")
|
||||
|
||||
async def resolve_boolean_details_async(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
return FlagResolutionDetails(value=True)
|
||||
|
||||
def resolve_boolean_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: bool,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[bool]:
|
||||
raise NotImplementedError("Use the async method")
|
||||
|
||||
def resolve_string_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: str,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[str]:
|
||||
raise NotImplementedError("Use the async method")
|
||||
|
||||
def resolve_integer_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: int,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[int]:
|
||||
raise NotImplementedError("Use the async method")
|
||||
|
||||
def resolve_float_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: float,
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[float]:
|
||||
raise NotImplementedError("Use the async method")
|
||||
|
||||
def resolve_object_details(
|
||||
self,
|
||||
flag_key: str,
|
||||
default_value: Union[dict, list],
|
||||
evaluation_context: Optional[EvaluationContext] = None,
|
||||
) -> FlagResolutionDetails[Union[dict, list]]:
|
||||
raise NotImplementedError("Use the async method")
|
||||
|
||||
# When
|
||||
set_provider(SyncNotImplementedProvider(), "SyncNotImplementedProvider")
|
||||
client = get_client("SyncNotImplementedProvider")
|
||||
flag = await client.get_boolean_value_async(flag_key="Key", default_value=False)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag is True
|
|
@ -0,0 +1,101 @@
|
|||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason
|
||||
from openfeature.hook import HookContext
|
||||
from openfeature.provider import Metadata
|
||||
from openfeature.telemetry import (
|
||||
TelemetryAttribute,
|
||||
TelemetryBodyField,
|
||||
TelemetryFlagMetadata,
|
||||
create_evaluation_event,
|
||||
)
|
||||
|
||||
|
||||
def test_create_evaluation_event():
|
||||
# given
|
||||
hook_context = HookContext(
|
||||
flag_key="flag_key",
|
||||
flag_type=FlagType.BOOLEAN,
|
||||
default_value=True,
|
||||
evaluation_context=EvaluationContext(),
|
||||
provider_metadata=Metadata(name="test_provider"),
|
||||
)
|
||||
details = FlagEvaluationDetails(
|
||||
flag_key=hook_context.flag_key,
|
||||
value=False,
|
||||
reason=Reason.CACHED,
|
||||
)
|
||||
|
||||
# when
|
||||
event = create_evaluation_event(hook_context=hook_context, details=details)
|
||||
|
||||
# then
|
||||
assert event.name == "feature_flag.evaluation"
|
||||
assert event.attributes[TelemetryAttribute.KEY] == "flag_key"
|
||||
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "cached"
|
||||
assert event.attributes[TelemetryAttribute.PROVIDER_NAME] == "test_provider"
|
||||
assert event.body[TelemetryBodyField.VALUE] is False
|
||||
|
||||
|
||||
def test_create_evaluation_event_with_variant():
|
||||
# given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
|
||||
details = FlagEvaluationDetails(
|
||||
flag_key=hook_context.flag_key,
|
||||
value=True,
|
||||
variant="true",
|
||||
)
|
||||
|
||||
# when
|
||||
event = create_evaluation_event(hook_context=hook_context, details=details)
|
||||
|
||||
# then
|
||||
assert event.name == "feature_flag.evaluation"
|
||||
assert event.attributes[TelemetryAttribute.KEY] == "flag_key"
|
||||
assert event.attributes[TelemetryAttribute.VARIANT] == "true"
|
||||
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "unknown"
|
||||
|
||||
|
||||
def test_create_evaluation_event_with_metadata():
|
||||
# given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
|
||||
details = FlagEvaluationDetails(
|
||||
flag_key=hook_context.flag_key,
|
||||
value=False,
|
||||
flag_metadata={
|
||||
TelemetryFlagMetadata.CONTEXT_ID: "5157782b-2203-4c80-a857-dbbd5e7761db",
|
||||
TelemetryFlagMetadata.FLAG_SET_ID: "proj-1",
|
||||
TelemetryFlagMetadata.VERSION: "v1",
|
||||
},
|
||||
)
|
||||
|
||||
# when
|
||||
event = create_evaluation_event(hook_context=hook_context, details=details)
|
||||
|
||||
# then
|
||||
assert (
|
||||
event.attributes[TelemetryAttribute.CONTEXT_ID]
|
||||
== "5157782b-2203-4c80-a857-dbbd5e7761db"
|
||||
)
|
||||
assert event.attributes[TelemetryAttribute.SET_ID] == "proj-1"
|
||||
assert event.attributes[TelemetryAttribute.VERSION] == "v1"
|
||||
|
||||
|
||||
def test_create_evaluation_event_with_error():
|
||||
# given
|
||||
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
|
||||
details = FlagEvaluationDetails(
|
||||
flag_key=hook_context.flag_key,
|
||||
value=False,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.FLAG_NOT_FOUND,
|
||||
error_message="flag error",
|
||||
)
|
||||
|
||||
# when
|
||||
event = create_evaluation_event(hook_context=hook_context, details=details)
|
||||
|
||||
# then
|
||||
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "error"
|
||||
assert event.attributes[TelemetryAttribute.ERROR_TYPE] == "flag_not_found"
|
||||
assert event.attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] == "flag error"
|
|
@ -2,33 +2,35 @@ from unittest.mock import MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from open_feature.api import (
|
||||
get_client,
|
||||
get_provider,
|
||||
set_provider,
|
||||
get_provider_metadata,
|
||||
get_evaluation_context,
|
||||
set_evaluation_context,
|
||||
get_hooks,
|
||||
from openfeature.api import (
|
||||
add_handler,
|
||||
add_hooks,
|
||||
clear_hooks,
|
||||
clear_providers,
|
||||
get_client,
|
||||
get_evaluation_context,
|
||||
get_hooks,
|
||||
get_provider_metadata,
|
||||
remove_handler,
|
||||
set_evaluation_context,
|
||||
set_provider,
|
||||
shutdown,
|
||||
)
|
||||
from open_feature.evaluation_context import EvaluationContext
|
||||
from open_feature.exception import ErrorCode, GeneralError
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.metadata import Metadata
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
|
||||
from openfeature.exception import ErrorCode, GeneralError, ProviderFatalError
|
||||
from openfeature.hook import Hook
|
||||
from openfeature.provider import FeatureProvider, Metadata, ProviderStatus
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
|
||||
|
||||
def test_should_not_raise_exception_with_noop_client():
|
||||
# Given
|
||||
# No provider has been set
|
||||
# When
|
||||
client = get_client(name="Default Provider", version="1.0")
|
||||
client = get_client()
|
||||
|
||||
# Then
|
||||
assert client.name == "Default Provider"
|
||||
assert client.version == "1.0"
|
||||
assert isinstance(client.provider, NoOpProvider)
|
||||
|
||||
|
||||
|
@ -37,11 +39,9 @@ def test_should_return_open_feature_client_when_configured_correctly():
|
|||
set_provider(NoOpProvider())
|
||||
|
||||
# When
|
||||
client = get_client(name="No-op Provider", version="1.0")
|
||||
client = get_client()
|
||||
|
||||
# Then
|
||||
assert client.name == "No-op Provider"
|
||||
assert client.version == "1.0"
|
||||
assert isinstance(client.provider, NoOpProvider)
|
||||
|
||||
|
||||
|
@ -56,16 +56,30 @@ def test_should_try_set_provider_and_fail_if_none_provided():
|
|||
assert ge.value.error_code == ErrorCode.GENERAL
|
||||
|
||||
|
||||
def test_should_return_a_provider_if_setup_correctly():
|
||||
def test_should_invoke_provider_initialize_function_on_newly_registered_provider():
|
||||
# Given
|
||||
set_provider(NoOpProvider())
|
||||
evaluation_context = EvaluationContext("targeting_key", {"attr1": "val1"})
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
|
||||
# When
|
||||
provider = get_provider()
|
||||
set_evaluation_context(evaluation_context)
|
||||
set_provider(provider)
|
||||
|
||||
# Then
|
||||
assert provider
|
||||
assert isinstance(provider, NoOpProvider)
|
||||
provider.initialize.assert_called_with(evaluation_context)
|
||||
|
||||
|
||||
def test_should_invoke_provider_shutdown_function_once_provider_is_no_longer_in_use():
|
||||
# Given
|
||||
provider_1 = MagicMock(spec=FeatureProvider)
|
||||
provider_2 = MagicMock(spec=FeatureProvider)
|
||||
|
||||
# When
|
||||
set_provider(provider_1)
|
||||
set_provider(provider_2)
|
||||
|
||||
# Then
|
||||
assert provider_1.shutdown.called
|
||||
|
||||
|
||||
def test_should_retrieve_metadata_for_configured_provider():
|
||||
|
@ -116,3 +130,230 @@ def test_should_add_hooks_to_api_hooks():
|
|||
|
||||
# Then
|
||||
assert get_hooks() == [hook_1, hook_2]
|
||||
|
||||
|
||||
def test_should_call_provider_shutdown_on_api_shutdown():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider)
|
||||
|
||||
# When
|
||||
shutdown()
|
||||
|
||||
# Then
|
||||
assert provider.shutdown.called
|
||||
|
||||
|
||||
def test_should_provide_a_function_to_bind_provider_through_domain():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
test_client = get_client("test")
|
||||
default_client = get_client()
|
||||
|
||||
# When
|
||||
set_provider(provider, domain="test")
|
||||
|
||||
# Then
|
||||
assert default_client.provider != provider
|
||||
assert default_client.domain is None
|
||||
|
||||
assert test_client.provider == provider
|
||||
assert test_client.domain == "test"
|
||||
|
||||
|
||||
def test_should_not_initialize_provider_already_bound_to_another_domain():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider, "foo")
|
||||
|
||||
# When
|
||||
set_provider(provider, "bar")
|
||||
|
||||
# Then
|
||||
provider.initialize.assert_called_once()
|
||||
|
||||
|
||||
def test_should_shutdown_unbound_provider():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider, "foo")
|
||||
|
||||
# When
|
||||
other_provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(other_provider, "foo")
|
||||
|
||||
provider.shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_should_not_shutdown_provider_bound_to_another_domain():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider, "foo")
|
||||
set_provider(provider, "bar")
|
||||
|
||||
# When
|
||||
other_provider = MagicMock(spec=FeatureProvider)
|
||||
set_provider(other_provider, "foo")
|
||||
|
||||
provider.shutdown.assert_not_called()
|
||||
|
||||
|
||||
def test_shutdown_should_shutdown_every_registered_provider_once():
|
||||
# Given
|
||||
provider_1 = MagicMock(spec=FeatureProvider)
|
||||
provider_2 = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider_1)
|
||||
set_provider(provider_1, "foo")
|
||||
set_provider(provider_2, "bar")
|
||||
set_provider(provider_2, "baz")
|
||||
|
||||
# When
|
||||
shutdown()
|
||||
|
||||
# Then
|
||||
provider_1.shutdown.assert_called_once()
|
||||
provider_2.shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_providers_shutdowns_every_provider_and_resets_default_provider():
|
||||
# Given
|
||||
provider_1 = MagicMock(spec=FeatureProvider)
|
||||
provider_2 = MagicMock(spec=FeatureProvider)
|
||||
set_provider(provider_1)
|
||||
set_provider(provider_2, "foo")
|
||||
set_provider(provider_2, "bar")
|
||||
|
||||
# When
|
||||
clear_providers()
|
||||
|
||||
# Then
|
||||
provider_1.shutdown.assert_called_once()
|
||||
provider_2.shutdown.assert_called_once()
|
||||
assert isinstance(get_client().provider, NoOpProvider)
|
||||
|
||||
|
||||
def test_provider_events():
|
||||
# Given
|
||||
spy = MagicMock()
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
|
||||
add_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
|
||||
add_handler(ProviderEvent.PROVIDER_STALE, spy.provider_stale)
|
||||
|
||||
provider_details = ProviderEventDetails(message="message")
|
||||
details = EventDetails.from_provider_event_details(
|
||||
provider.get_metadata().name, provider_details
|
||||
)
|
||||
|
||||
# When
|
||||
provider.emit_provider_configuration_changed(provider_details)
|
||||
provider.emit_provider_error(provider_details)
|
||||
provider.emit_provider_stale(provider_details)
|
||||
|
||||
# Then
|
||||
# NOTE: provider_ready is called immediately after adding the handler
|
||||
spy.provider_ready.assert_called_once()
|
||||
spy.provider_configuration_changed.assert_called_once_with(details)
|
||||
spy.provider_error.assert_called_once_with(details)
|
||||
spy.provider_stale.assert_called_once_with(details)
|
||||
|
||||
|
||||
def test_add_remove_event_handler():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
spy = MagicMock()
|
||||
|
||||
add_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
remove_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
|
||||
provider_details = ProviderEventDetails(message="message")
|
||||
|
||||
# When
|
||||
provider.emit_provider_configuration_changed(provider_details)
|
||||
|
||||
# Then
|
||||
spy.provider_configuration_changed.assert_not_called()
|
||||
|
||||
|
||||
# Requirement 5.3.3
|
||||
def test_handlers_attached_to_provider_already_in_associated_state_should_run_immediately():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
spy = MagicMock()
|
||||
|
||||
# When
|
||||
add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
|
||||
|
||||
# Then
|
||||
spy.provider_ready.assert_called_once()
|
||||
|
||||
|
||||
def test_provider_ready_handlers_run_if_provider_initialize_function_terminates_normally():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
|
||||
spy = MagicMock()
|
||||
add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
|
||||
spy.reset_mock() # reset the mock to avoid counting the immediate call on subscribe
|
||||
|
||||
# When
|
||||
set_provider(provider)
|
||||
|
||||
# Then
|
||||
spy.provider_ready.assert_called_once()
|
||||
|
||||
|
||||
def test_provider_error_handlers_run_if_provider_initialize_function_terminates_abnormally():
|
||||
# Given
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
provider.initialize.side_effect = ProviderFatalError()
|
||||
|
||||
spy = MagicMock()
|
||||
add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
|
||||
|
||||
# When
|
||||
set_provider(provider)
|
||||
|
||||
# Then
|
||||
spy.provider_error.assert_called_once()
|
||||
|
||||
|
||||
def test_provider_status_is_updated_after_provider_emits_event():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
client = get_client()
|
||||
|
||||
# When
|
||||
provider.emit_provider_error(ProviderEventDetails(error_code=ErrorCode.GENERAL))
|
||||
# Then
|
||||
assert client.get_provider_status() == ProviderStatus.ERROR
|
||||
|
||||
# When
|
||||
provider.emit_provider_error(
|
||||
ProviderEventDetails(error_code=ErrorCode.PROVIDER_FATAL)
|
||||
)
|
||||
# Then
|
||||
assert client.get_provider_status() == ProviderStatus.FATAL
|
||||
|
||||
# When
|
||||
provider.emit_provider_stale(ProviderEventDetails())
|
||||
# Then
|
||||
assert client.get_provider_status() == ProviderStatus.STALE
|
||||
|
||||
# When
|
||||
provider.emit_provider_ready(ProviderEventDetails())
|
||||
# Then
|
||||
assert client.get_provider_status() == ProviderStatus.READY
|
||||
|
|
|
@ -1,22 +1,36 @@
|
|||
import inspect
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from open_feature.api import add_hooks, clear_hooks
|
||||
from open_feature.client import OpenFeatureClient
|
||||
from open_feature.exception import ErrorCode, OpenFeatureError
|
||||
from open_feature.flag_evaluation import Reason
|
||||
from open_feature.hook import Hook
|
||||
from open_feature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature import api
|
||||
from openfeature.api import add_hooks, clear_hooks, get_client, set_provider
|
||||
from openfeature.client import OpenFeatureClient, _typecheck_flag_value
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
|
||||
from openfeature.exception import ErrorCode, OpenFeatureError
|
||||
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason
|
||||
from openfeature.hook import Hook
|
||||
from openfeature.provider import FeatureProvider, ProviderStatus
|
||||
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag_type, default_value, get_method",
|
||||
(
|
||||
(bool, True, "get_boolean_value"),
|
||||
(bool, True, "get_boolean_value_async"),
|
||||
(str, "String", "get_string_value"),
|
||||
(str, "String", "get_string_value_async"),
|
||||
(int, 100, "get_integer_value"),
|
||||
(int, 100, "get_integer_value_async"),
|
||||
(float, 10.23, "get_float_value"),
|
||||
(float, 10.23, "get_float_value_async"),
|
||||
(
|
||||
dict,
|
||||
{
|
||||
|
@ -26,21 +40,38 @@ from open_feature.provider.no_op_provider import NoOpProvider
|
|||
},
|
||||
"get_object_value",
|
||||
),
|
||||
(
|
||||
dict,
|
||||
{
|
||||
"String": "string",
|
||||
"Number": 2,
|
||||
"Boolean": True,
|
||||
},
|
||||
"get_object_value_async",
|
||||
),
|
||||
(
|
||||
list,
|
||||
["string1", "string2"],
|
||||
"get_object_value",
|
||||
),
|
||||
(
|
||||
list,
|
||||
["string1", "string2"],
|
||||
"get_object_value_async",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_should_get_flag_value_based_on_method_type(
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_get_flag_value_based_on_method_type(
|
||||
flag_type, default_value, get_method, no_op_provider_client
|
||||
):
|
||||
# Given
|
||||
# When
|
||||
flag = getattr(no_op_provider_client, get_method)(
|
||||
flag_key="Key", default_value=default_value
|
||||
)
|
||||
method = getattr(no_op_provider_client, get_method)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
flag = await method(flag_key="Key", default_value=default_value)
|
||||
else:
|
||||
flag = method(flag_key="Key", default_value=default_value)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag == default_value
|
||||
|
@ -51,9 +82,13 @@ def test_should_get_flag_value_based_on_method_type(
|
|||
"flag_type, default_value, get_method",
|
||||
(
|
||||
(bool, True, "get_boolean_details"),
|
||||
(bool, True, "get_boolean_details_async"),
|
||||
(str, "String", "get_string_details"),
|
||||
(str, "String", "get_string_details_async"),
|
||||
(int, 100, "get_integer_details"),
|
||||
(int, 100, "get_integer_details_async"),
|
||||
(float, 10.23, "get_float_details"),
|
||||
(float, 10.23, "get_float_details_async"),
|
||||
(
|
||||
dict,
|
||||
{
|
||||
|
@ -63,38 +98,85 @@ def test_should_get_flag_value_based_on_method_type(
|
|||
},
|
||||
"get_object_details",
|
||||
),
|
||||
(
|
||||
dict,
|
||||
{
|
||||
"String": "string",
|
||||
"Number": 2,
|
||||
"Boolean": True,
|
||||
},
|
||||
"get_object_details_async",
|
||||
),
|
||||
(
|
||||
list,
|
||||
["string1", "string2"],
|
||||
"get_object_details",
|
||||
),
|
||||
(
|
||||
list,
|
||||
["string1", "string2"],
|
||||
"get_object_details_async",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_should_get_flag_detail_based_on_method_type(
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_get_flag_detail_based_on_method_type(
|
||||
flag_type, default_value, get_method, no_op_provider_client
|
||||
):
|
||||
# Given
|
||||
# When
|
||||
flag = getattr(no_op_provider_client, get_method)(
|
||||
flag_key="Key", default_value=default_value
|
||||
)
|
||||
method = getattr(no_op_provider_client, get_method)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
flag = await method(flag_key="Key", default_value=default_value)
|
||||
else:
|
||||
flag = method(flag_key="Key", default_value=default_value)
|
||||
# Then
|
||||
assert flag is not None
|
||||
assert flag.value == default_value
|
||||
assert isinstance(flag.value, flag_type)
|
||||
|
||||
|
||||
def test_should_raise_exception_when_invalid_flag_type_provided(no_op_provider_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_raise_exception_when_invalid_flag_type_provided(
|
||||
no_op_provider_client,
|
||||
):
|
||||
# Given
|
||||
# When
|
||||
flag = no_op_provider_client.evaluate_flag_details(
|
||||
flag_sync = no_op_provider_client.evaluate_flag_details(
|
||||
flag_type=None, flag_key="Key", default_value=True
|
||||
)
|
||||
flag_async = await no_op_provider_client.evaluate_flag_details_async(
|
||||
flag_type=None, flag_key="Key", default_value=True
|
||||
)
|
||||
# Then
|
||||
assert flag.value
|
||||
assert flag.error_message == "Unknown flag type"
|
||||
assert flag.error_code == ErrorCode.GENERAL
|
||||
assert flag.reason == Reason.ERROR
|
||||
for flag in [flag_sync, flag_async]:
|
||||
assert flag.value
|
||||
assert flag.error_message == "Unknown flag type"
|
||||
assert flag.error_code == ErrorCode.GENERAL
|
||||
assert flag.reason == Reason.ERROR
|
||||
|
||||
|
||||
def test_should_pass_flag_metadata_from_resolution_to_evaluation_details():
|
||||
# Given
|
||||
provider = InMemoryProvider(
|
||||
{
|
||||
"Key": InMemoryFlag(
|
||||
"true",
|
||||
{"true": True, "false": False},
|
||||
flag_metadata={"foo": "bar"},
|
||||
)
|
||||
}
|
||||
)
|
||||
set_provider(provider, "my-client")
|
||||
|
||||
client = OpenFeatureClient("my-client", None)
|
||||
|
||||
# When
|
||||
details = client.get_boolean_details(flag_key="Key", default_value=False)
|
||||
|
||||
# Then
|
||||
assert details is not None
|
||||
assert details.flag_metadata == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_should_handle_a_generic_exception_thrown_by_a_provider(no_op_provider_client):
|
||||
|
@ -136,14 +218,14 @@ def test_should_handle_an_open_feature_exception_thrown_by_a_provider(
|
|||
assert flag_details.error_message == "error_message"
|
||||
|
||||
|
||||
def test_should_return_client_metadata_with_name():
|
||||
def test_should_return_client_metadata_with_domain():
|
||||
# Given
|
||||
client = OpenFeatureClient("my-client", None, NoOpProvider())
|
||||
# When
|
||||
metadata = client.get_metadata()
|
||||
# Then
|
||||
assert metadata is not None
|
||||
assert metadata.name == "my-client"
|
||||
assert metadata.domain == "my-client"
|
||||
|
||||
|
||||
def test_should_call_api_level_hooks(no_op_provider_client):
|
||||
|
@ -158,3 +240,320 @@ def test_should_call_api_level_hooks(no_op_provider_client):
|
|||
# Then
|
||||
api_hook.before.assert_called_once()
|
||||
api_hook.after.assert_called_once()
|
||||
|
||||
|
||||
# Requirement 1.7.5
|
||||
def test_should_define_a_provider_status_accessor(no_op_provider_client):
|
||||
# When
|
||||
status = no_op_provider_client.get_provider_status()
|
||||
# Then
|
||||
assert status is not None
|
||||
assert status == ProviderStatus.READY
|
||||
|
||||
|
||||
# Requirement 1.7.6
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_shortcircuit_if_provider_is_not_ready(
|
||||
no_op_provider_client, monkeypatch
|
||||
):
|
||||
# Given
|
||||
monkeypatch.setattr(
|
||||
no_op_provider_client, "get_provider_status", lambda: ProviderStatus.NOT_READY
|
||||
)
|
||||
spy_hook = MagicMock(spec=Hook)
|
||||
no_op_provider_client.add_hooks([spy_hook])
|
||||
# When
|
||||
flag_details_sync = no_op_provider_client.get_boolean_details(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
spy_hook.error.assert_called_once()
|
||||
spy_hook.reset_mock()
|
||||
flag_details_async = await no_op_provider_client.get_boolean_details_async(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
# Then
|
||||
for flag_details in [flag_details_sync, flag_details_async]:
|
||||
assert flag_details is not None
|
||||
assert flag_details.value
|
||||
assert flag_details.reason == Reason.ERROR
|
||||
assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY
|
||||
spy_hook.error.assert_called_once()
|
||||
spy_hook.finally_after.assert_called_once()
|
||||
|
||||
|
||||
# Requirement 1.7.7
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state(
|
||||
no_op_provider_client, monkeypatch
|
||||
):
|
||||
# Given
|
||||
monkeypatch.setattr(
|
||||
no_op_provider_client, "get_provider_status", lambda: ProviderStatus.FATAL
|
||||
)
|
||||
spy_hook = MagicMock(spec=Hook)
|
||||
no_op_provider_client.add_hooks([spy_hook])
|
||||
# When
|
||||
flag_details_sync = no_op_provider_client.get_boolean_details(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
spy_hook.error.assert_called_once()
|
||||
spy_hook.reset_mock()
|
||||
flag_details_async = await no_op_provider_client.get_boolean_details_async(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
# Then
|
||||
for flag_details in [flag_details_sync, flag_details_async]:
|
||||
assert flag_details is not None
|
||||
assert flag_details.value
|
||||
assert flag_details.reason == Reason.ERROR
|
||||
assert flag_details.error_code == ErrorCode.PROVIDER_FATAL
|
||||
spy_hook.error.assert_called_once()
|
||||
spy_hook.finally_after.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code():
|
||||
# Given
|
||||
spy_hook = MagicMock(spec=Hook)
|
||||
provider = MagicMock(spec=FeatureProvider)
|
||||
provider.get_provider_hooks.return_value = []
|
||||
mock_resolution = FlagResolutionDetails(
|
||||
value=True,
|
||||
reason=Reason.ERROR,
|
||||
error_code=ErrorCode.PROVIDER_FATAL,
|
||||
error_message="This is an error message",
|
||||
)
|
||||
provider.resolve_boolean_details.return_value = mock_resolution
|
||||
provider.resolve_boolean_details_async.return_value = mock_resolution
|
||||
set_provider(provider)
|
||||
client = get_client()
|
||||
client.add_hooks([spy_hook])
|
||||
# When
|
||||
flag_details_sync = client.get_boolean_details(flag_key="Key", default_value=True)
|
||||
spy_hook.error.assert_called_once()
|
||||
spy_hook.reset_mock()
|
||||
flag_details_async = await client.get_boolean_details_async(
|
||||
flag_key="Key", default_value=True
|
||||
)
|
||||
# Then
|
||||
for flag_details in [flag_details_sync, flag_details_async]:
|
||||
assert flag_details is not None
|
||||
assert flag_details.value
|
||||
assert flag_details.reason == Reason.ERROR
|
||||
assert flag_details.error_code == ErrorCode.PROVIDER_FATAL
|
||||
spy_hook.error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_type_mismatch_exceptions():
|
||||
# Given
|
||||
client = get_client()
|
||||
# When
|
||||
flag_details_sync = client.get_boolean_details(
|
||||
flag_key="Key", default_value="type mismatch"
|
||||
)
|
||||
flag_details_async = await client.get_boolean_details_async(
|
||||
flag_key="Key", default_value="type mismatch"
|
||||
)
|
||||
# Then
|
||||
for flag_details in [flag_details_sync, flag_details_async]:
|
||||
assert flag_details is not None
|
||||
assert flag_details.value
|
||||
assert flag_details.reason == Reason.ERROR
|
||||
assert flag_details.error_code == ErrorCode.TYPE_MISMATCH
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typecheck_flag_value_general_error():
|
||||
# Given
|
||||
flag_value = "A"
|
||||
flag_type = None
|
||||
# When
|
||||
err = _typecheck_flag_value(value=flag_value, flag_type=flag_type)
|
||||
# Then
|
||||
assert err.error_code == ErrorCode.GENERAL
|
||||
assert err.error_message == "Unknown flag type"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typecheck_flag_value_type_mismatch_error():
|
||||
# Given
|
||||
flag_value = "A"
|
||||
flag_type = FlagType.BOOLEAN
|
||||
# When
|
||||
err = _typecheck_flag_value(value=flag_value, flag_type=flag_type)
|
||||
# Then
|
||||
assert err.error_code == ErrorCode.TYPE_MISMATCH
|
||||
assert err.error_message == "Expected type <class 'bool'> but got <class 'str'>"
|
||||
|
||||
|
||||
def test_provider_events():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
other_provider = NoOpProvider()
|
||||
set_provider(other_provider, "my-domain")
|
||||
|
||||
provider_details = ProviderEventDetails(message="message")
|
||||
details = EventDetails.from_provider_event_details(
|
||||
provider.get_metadata().name, provider_details
|
||||
)
|
||||
|
||||
def emit_all_events(provider):
|
||||
provider.emit_provider_configuration_changed(provider_details)
|
||||
provider.emit_provider_error(provider_details)
|
||||
provider.emit_provider_stale(provider_details)
|
||||
|
||||
spy = MagicMock()
|
||||
|
||||
client = get_client()
|
||||
client.add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
|
||||
client.add_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
client.add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
|
||||
client.add_handler(ProviderEvent.PROVIDER_STALE, spy.provider_stale)
|
||||
|
||||
# When
|
||||
emit_all_events(provider)
|
||||
emit_all_events(other_provider)
|
||||
|
||||
# Then
|
||||
# NOTE: provider_ready is called immediately after adding the handler
|
||||
spy.provider_ready.assert_called_once()
|
||||
spy.provider_configuration_changed.assert_called_once_with(details)
|
||||
spy.provider_error.assert_called_once_with(details)
|
||||
spy.provider_stale.assert_called_once_with(details)
|
||||
|
||||
|
||||
def test_add_remove_event_handler():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
spy = MagicMock()
|
||||
|
||||
client = get_client()
|
||||
client.add_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
client.remove_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
|
||||
provider_details = ProviderEventDetails(message="message")
|
||||
|
||||
# When
|
||||
provider.emit_provider_configuration_changed(provider_details)
|
||||
|
||||
# Then
|
||||
spy.provider_configuration_changed.assert_not_called()
|
||||
|
||||
|
||||
# Requirement 5.1.2, Requirement 5.1.3
|
||||
def test_provider_event_late_binding():
|
||||
# Given
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider, "my-domain")
|
||||
other_provider = NoOpProvider()
|
||||
|
||||
spy = MagicMock()
|
||||
|
||||
client = get_client("my-domain")
|
||||
client.add_handler(
|
||||
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
|
||||
)
|
||||
|
||||
set_provider(other_provider, "my-domain")
|
||||
|
||||
provider_details = ProviderEventDetails(message="message from provider")
|
||||
other_provider_details = ProviderEventDetails(message="message from other provider")
|
||||
|
||||
details = EventDetails.from_provider_event_details(
|
||||
other_provider.get_metadata().name, other_provider_details
|
||||
)
|
||||
|
||||
# When
|
||||
provider.emit_provider_configuration_changed(provider_details)
|
||||
other_provider.emit_provider_configuration_changed(other_provider_details)
|
||||
|
||||
# Then
|
||||
spy.provider_configuration_changed.assert_called_once_with(details)
|
||||
|
||||
|
||||
def test_client_handlers_thread_safety():
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
def add_handlers_task():
|
||||
def handler(*args, **kwargs):
|
||||
time.sleep(0.005)
|
||||
|
||||
for _ in range(10):
|
||||
time.sleep(0.01)
|
||||
client = get_client(str(uuid.uuid4()))
|
||||
client.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler)
|
||||
|
||||
def emit_events_task():
|
||||
for _ in range(10):
|
||||
time.sleep(0.01)
|
||||
provider.emit_provider_configuration_changed(ProviderEventDetails())
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
f1 = executor.submit(add_handlers_task)
|
||||
f2 = executor.submit(emit_events_task)
|
||||
f1.result()
|
||||
f2.result()
|
||||
|
||||
|
||||
def test_client_should_merge_contexts():
|
||||
api.clear_hooks()
|
||||
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
|
||||
|
||||
provider = NoOpProvider()
|
||||
provider.resolve_boolean_details = MagicMock(wraps=provider.resolve_boolean_details)
|
||||
api.set_provider(provider)
|
||||
|
||||
# Global evaluation context
|
||||
global_context = EvaluationContext(
|
||||
targeting_key="global", attributes={"global_attr": "global_value"}
|
||||
)
|
||||
api.set_evaluation_context(global_context)
|
||||
|
||||
# Transaction context
|
||||
transaction_context = EvaluationContext(
|
||||
targeting_key="transaction",
|
||||
attributes={"transaction_attr": "transaction_value"},
|
||||
)
|
||||
api.set_transaction_context(transaction_context)
|
||||
|
||||
# Client-specific context
|
||||
client_context = EvaluationContext(
|
||||
targeting_key="client", attributes={"client_attr": "client_value"}
|
||||
)
|
||||
client = OpenFeatureClient(domain=None, version=None, context=client_context)
|
||||
|
||||
# Invocation-specific context
|
||||
invocation_context = EvaluationContext(
|
||||
targeting_key="invocation", attributes={"invocation_attr": "invocation_value"}
|
||||
)
|
||||
flag_input = "flag"
|
||||
flag_default = False
|
||||
client.get_boolean_details(flag_input, flag_default, invocation_context)
|
||||
|
||||
# Retrieve the call arguments
|
||||
args, kwargs = provider.resolve_boolean_details.call_args
|
||||
flag_key, default_value, context = (
|
||||
kwargs["flag_key"],
|
||||
kwargs["default_value"],
|
||||
kwargs["evaluation_context"],
|
||||
)
|
||||
|
||||
assert flag_key == flag_input
|
||||
assert default_value is flag_default
|
||||
assert context.targeting_key == "invocation" # Last one in the merge chain
|
||||
assert context.attributes["global_attr"] == "global_value"
|
||||
assert context.attributes["transaction_attr"] == "transaction_value"
|
||||
assert context.attributes["client_attr"] == "client_value"
|
||||
assert context.attributes["invocation_attr"] == "invocation_value"
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
from open_feature.exception import ErrorCode
|
||||
from open_feature.flag_evaluation import FlagEvaluationDetails, Reason
|
||||
from openfeature.exception import ErrorCode
|
||||
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
|
||||
|
||||
|
||||
def test_evaulation_details_reason_should_be_a_string():
|
||||
def test_evaluation_details_reason_should_be_a_string():
|
||||
# Given
|
||||
flag_key = "my-flag"
|
||||
flag_value = 100
|
||||
variant = "1-hundred"
|
||||
flag_metadata = {}
|
||||
reason = Reason.DEFAULT
|
||||
error_code = ErrorCode.GENERAL
|
||||
error_message = "message"
|
||||
|
@ -16,6 +17,7 @@ def test_evaulation_details_reason_should_be_a_string():
|
|||
flag_key,
|
||||
flag_value,
|
||||
variant,
|
||||
flag_metadata,
|
||||
reason,
|
||||
error_code,
|
||||
error_message,
|
||||
|
@ -30,7 +32,7 @@ def test_evaulation_details_reason_should_be_a_string():
|
|||
assert reason == flag_details.reason
|
||||
|
||||
|
||||
def test_evaulation_details_reason_should_be_a_string_when_set():
|
||||
def test_evaluation_details_reason_should_be_a_string_when_set():
|
||||
# Given
|
||||
flag_key = "my-flag"
|
||||
flag_value = 100
|
||||
|
@ -51,4 +53,4 @@ def test_evaulation_details_reason_should_be_a_string_when_set():
|
|||
flag_details.reason = Reason.STATIC
|
||||
|
||||
# Then
|
||||
assert Reason.STATIC == flag_details.reason
|
||||
assert Reason.STATIC == flag_details.reason # noqa: SIM300
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
import asyncio
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from openfeature.api import (
|
||||
get_transaction_context,
|
||||
set_transaction_context,
|
||||
set_transaction_context_propagator,
|
||||
)
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.transaction_context import (
|
||||
ContextVarsTransactionContextPropagator,
|
||||
TransactionContextPropagator,
|
||||
)
|
||||
from openfeature.transaction_context.no_op_transaction_context_propagator import (
|
||||
NoOpTransactionContextPropagator,
|
||||
)
|
||||
|
||||
|
||||
# Test cases
|
||||
def test_should_return_default_evaluation_context_with_noop_propagator():
|
||||
# Given
|
||||
set_transaction_context_propagator(NoOpTransactionContextPropagator())
|
||||
|
||||
# When
|
||||
context = get_transaction_context()
|
||||
|
||||
# Then
|
||||
assert isinstance(context, EvaluationContext)
|
||||
assert context.attributes == {}
|
||||
|
||||
|
||||
def test_should_set_and_get_custom_transaction_context():
|
||||
# Given
|
||||
set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
|
||||
evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"})
|
||||
|
||||
# When
|
||||
set_transaction_context(evaluation_context)
|
||||
|
||||
# Then
|
||||
context = get_transaction_context()
|
||||
assert context.targeting_key == "custom_key"
|
||||
assert context.attributes == {"attr1": "val1"}
|
||||
|
||||
|
||||
def test_should_override_propagator_and_reset_context():
|
||||
# Given
|
||||
custom_propagator = MagicMock(spec=TransactionContextPropagator)
|
||||
default_context = EvaluationContext()
|
||||
|
||||
set_transaction_context_propagator(custom_propagator)
|
||||
|
||||
# When
|
||||
set_transaction_context_propagator(NoOpTransactionContextPropagator())
|
||||
|
||||
# Then
|
||||
assert get_transaction_context() == default_context
|
||||
|
||||
|
||||
def test_should_call_set_transaction_context_on_propagator():
|
||||
# Given
|
||||
custom_propagator = MagicMock(spec=TransactionContextPropagator)
|
||||
evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"})
|
||||
set_transaction_context_propagator(custom_propagator)
|
||||
|
||||
# When
|
||||
set_transaction_context(evaluation_context)
|
||||
|
||||
# Then
|
||||
custom_propagator.set_transaction_context.assert_called_with(evaluation_context)
|
||||
|
||||
|
||||
def test_should_return_default_context_with_noop_propagator_set():
|
||||
# Given
|
||||
noop_propagator = NoOpTransactionContextPropagator()
|
||||
|
||||
set_transaction_context_propagator(noop_propagator)
|
||||
|
||||
# When
|
||||
context = get_transaction_context()
|
||||
|
||||
# Then
|
||||
assert context == EvaluationContext()
|
||||
|
||||
|
||||
def test_should_propagate_event_when_context_set():
|
||||
# Given
|
||||
custom_propagator = ContextVarsTransactionContextPropagator()
|
||||
set_transaction_context_propagator(custom_propagator)
|
||||
evaluation_context = EvaluationContext("custom_key", {"attr1": "val1"})
|
||||
|
||||
# When
|
||||
set_transaction_context(evaluation_context)
|
||||
|
||||
# Then
|
||||
assert (
|
||||
custom_propagator._transaction_context_var.get().targeting_key == "custom_key"
|
||||
)
|
||||
assert custom_propagator._transaction_context_var.get().attributes == {
|
||||
"attr1": "val1"
|
||||
}
|
||||
|
||||
|
||||
def test_context_vars_transaction_context_propagator_multiple_threads():
|
||||
# Given
|
||||
context_var_propagator = ContextVarsTransactionContextPropagator()
|
||||
set_transaction_context_propagator(context_var_propagator)
|
||||
|
||||
number_of_threads = 3
|
||||
barrier = threading.Barrier(number_of_threads)
|
||||
|
||||
def thread_func(context_value, result_list, index):
|
||||
context = EvaluationContext(
|
||||
f"context_{context_value}", {"thread": context_value}
|
||||
)
|
||||
set_transaction_context(context)
|
||||
barrier.wait()
|
||||
result_list[index] = get_transaction_context()
|
||||
|
||||
results = [None] * number_of_threads
|
||||
threads = []
|
||||
|
||||
# When
|
||||
for i in range(3):
|
||||
thread = threading.Thread(target=thread_func, args=(i, results, i))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Then
|
||||
for i in range(3):
|
||||
assert results[i].targeting_key == f"context_{i}"
|
||||
assert results[i].attributes == {"thread": i}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_vars_transaction_context_propagator_asyncio():
|
||||
# Given
|
||||
context_var_propagator = ContextVarsTransactionContextPropagator()
|
||||
set_transaction_context_propagator(context_var_propagator)
|
||||
|
||||
number_of_tasks = 3
|
||||
event = asyncio.Event()
|
||||
ready_count = 0
|
||||
|
||||
async def async_func(context_value, results, index):
|
||||
nonlocal ready_count
|
||||
context = EvaluationContext(
|
||||
f"context_{context_value}", {"async": context_value}
|
||||
)
|
||||
set_transaction_context(context)
|
||||
|
||||
ready_count += 1 # Increment the ready count
|
||||
if ready_count == number_of_tasks:
|
||||
event.set() # Set the event when all tasks are ready
|
||||
|
||||
await event.wait() # Wait for the event to be set
|
||||
results[index] = get_transaction_context()
|
||||
|
||||
# Placeholder for results
|
||||
results = [None] * number_of_tasks
|
||||
|
||||
# When
|
||||
tasks = [async_func(i, results, i) for i in range(number_of_tasks)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# Then
|
||||
for i in range(number_of_tasks):
|
||||
assert results[i].targeting_key == f"context_{i}"
|
||||
assert results[i].attributes == {"async": i}
|
|
@ -0,0 +1,51 @@
|
|||
from openfeature.api import (
|
||||
set_provider,
|
||||
set_transaction_context,
|
||||
set_transaction_context_propagator,
|
||||
)
|
||||
from openfeature.client import OpenFeatureClient
|
||||
from openfeature.evaluation_context import EvaluationContext
|
||||
from openfeature.hook import Hook
|
||||
from openfeature.provider.no_op_provider import NoOpProvider
|
||||
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
|
||||
|
||||
|
||||
class TransactionContextHook(Hook):
|
||||
def __init__(self):
|
||||
self.before_called = False
|
||||
self.transaction_attr_value = None
|
||||
|
||||
def before(self, hook_context, hints):
|
||||
self.before_called = True
|
||||
# Check if the transaction context attribute is in the hook context
|
||||
if "transaction_attr" in hook_context.evaluation_context.attributes:
|
||||
self.transaction_attr_value = hook_context.evaluation_context.attributes[
|
||||
"transaction_attr"
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def test_transaction_context_merged_into_hook_context():
|
||||
"""Test that transaction context is merged into the hook context's evaluation context."""
|
||||
set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
|
||||
|
||||
provider = NoOpProvider()
|
||||
set_provider(provider)
|
||||
|
||||
client = OpenFeatureClient(domain=None, version=None)
|
||||
|
||||
hook = TransactionContextHook()
|
||||
client.add_hooks([hook])
|
||||
|
||||
transaction_context = EvaluationContext(
|
||||
targeting_key="transaction",
|
||||
attributes={"transaction_attr": "transaction_value"},
|
||||
)
|
||||
set_transaction_context(transaction_context)
|
||||
|
||||
client.get_boolean_value(flag_key="test-flag", default_value=False)
|
||||
|
||||
assert hook.before_called, "Hook's before method was not called"
|
||||
assert hook.transaction_attr_value == "transaction_value", (
|
||||
"Transaction context attribute was not found in hook context"
|
||||
)
|
|
@ -0,0 +1,456 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "behave"
|
||||
version = "1.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "parse" },
|
||||
{ name = "parse-type" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696, upload-time = "2018-02-25T20:06:38.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779, upload-time = "2018-02-25T20:06:34.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/8f/6ac7fbb29e35645065f7be835bfe3e0cce567f80390de2f3db65d83cb5e3/coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867", size = 819816, upload-time = "2025-07-24T16:53:00.896Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f6/b2366476b1f48134757f2a42aaf00e7ce8e734eea5f3cf022df113116174/coverage-7.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbd823f7ea5286c26406ad9e54268544d82f3d1cadb6d4f3b85e9877f0cab1ef", size = 214813, upload-time = "2025-07-24T16:50:18.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d1/7e26bb4c41ed1b9aca4550187ca42557d79c70d318414a703d814858eacb/coverage-7.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab3f7a5dbaab937df0b9e9e8ec6eab235ba9a6f29d71fd3b24335affaed886cc", size = 215206, upload-time = "2025-07-24T16:50:21.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/71/d5ae128557c8d0ce0156eb1e980e5c6e6f7e54ef3e998c87ab4b3679ff45/coverage-7.10.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8c63aaf850523d8cbe3f5f1a5c78f689b223797bef902635f2493ab43498f36c", size = 242171, upload-time = "2025-07-24T16:50:23.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/87/d586a627e3b61cfe631ebcf3d8a38bf9085142800d2ac434bc20f3699880/coverage-7.10.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c3133ce3fa84023f7c6921c4dca711be0b658784c5a51a797168229eae26172", size = 243431, upload-time = "2025-07-24T16:50:24.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/cc/ff5c6f4f99a987ebd18a3350194377c7cefee9ddd6e532ede83a0a1f332c/coverage-7.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3747d1d0af85b17d3a156cd30e4bbacf893815e846dc6c07050e9769da2b138e", size = 245288, upload-time = "2025-07-24T16:50:26.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d9/2758e73d7fe496c04dd715af8bb8856354a1ad4cc11553d9096c4b35dc86/coverage-7.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:241923b350437f6a7cb343d9df72998305ef940c3c40009f06e05029a047677c", size = 243235, upload-time = "2025-07-24T16:50:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/9b/3c273dde651d83484992d7e7bcd9cd84a363f01026caf69716390bd79e0d/coverage-7.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13e82e499309307104d58ac66f9eed237f7aaceab4325416645be34064d9a2be", size = 241909, upload-time = "2025-07-24T16:50:30.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/7c/006d9f66035c4d414ea642d990854a30c23145551315bd0b38100daee168/coverage-7.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf73cdde4f6c9cd4457b00bf1696236796ac3a241f859a55e0f84a4c58326a7f", size = 242202, upload-time = "2025-07-24T16:50:32.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/42/80d8747f77c63593a2114c7299df52f7568168e4fd882d7d5ebe8181564f/coverage-7.10.0-cp310-cp310-win32.whl", hash = "sha256:2396e13275b37870a3345f58bce8b15a7e0a985771d13a4b16ce9129954e07d6", size = 217311, upload-time = "2025-07-24T16:50:33.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/8b/fe04c3851e5d290524f563a8a564c7e5dcd6b5ca35ed689ce662346de230/coverage-7.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d45c7c71fb3d2da92ab893602e3f28f2d1560cec765a27e1824a6e0f7e92cfd", size = 218199, upload-time = "2025-07-24T16:50:36.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5d/0d1ee021439e3b8b1e86ba92465f5a8d8e15b0222dcdd705606ef089f4fe/coverage-7.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4abc01843581a6f9dd72d4d15761861190973a2305416639435ef509288f7a04", size = 214934, upload-time = "2025-07-24T16:50:38.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b2/1e0727327e473aa1a68ca1c9922818a06061d05d44e0c5330109d091b525/coverage-7.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2093297773111d7d748fe4a99b68747e57994531fb5c57bbe439af17c11c169", size = 215320, upload-time = "2025-07-24T16:50:39.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/17/d231e37236863ae3bed7c51615af6b6fc89639c88adf35766d2880dcd7c7/coverage-7.10.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58240e27815bf105bd975c2fd42e700839f93d5aad034ef976411193ca32dbfd", size = 245321, upload-time = "2025-07-24T16:50:41.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/77/a285aba35bf6ec12c466474931410ef0e6fa85542169009443868e98820a/coverage-7.10.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d019eac999b40ad48521ea057958b07a9f549c0c6d257a20e5c7c4ba91af8d1c", size = 247155, upload-time = "2025-07-24T16:50:43.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/82/50512eafdd5938a7aa1550014e37fa1c2ca85516bfd85ffeb2f03eff052a/coverage-7.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0a1f5454bc80faf4ceab10d1d48f025f92046c9c0f3bec2e1a9dda55137f8", size = 249320, upload-time = "2025-07-24T16:50:44.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/7b/0ec1dc75c8f4d940d03d477b1e07269b4804dcab74ad1e294d40310aba47/coverage-7.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a93dd7759c416dd1cc754123b926d065055cb9a33b6699e64a1e5bdfae1ff459", size = 247047, upload-time = "2025-07-24T16:50:46.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5b/40f9b78ae98c2f511a2b062660906e126aadcd35870b9190a4f10f2820ae/coverage-7.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7b3d737266048368a6ffd68f1ecd662c54de56535c82eb8f98a55ac216a72cbd", size = 245078, upload-time = "2025-07-24T16:50:47.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/672c2a728e77846be7fcc4baaa003e0df86a2174aeb8921d132c14c333d4/coverage-7.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93227c2707cb0effd9163cd0d8f0d9ab628982f7a3e915d6d64c7107867b9a07", size = 245686, upload-time = "2025-07-24T16:50:49.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/f3/fa078f0bfae7f0e6b14c426f9cb095f4809314d926c89b9a2641fb4ca482/coverage-7.10.0-cp311-cp311-win32.whl", hash = "sha256:69270af3014ab3058ad6108c6d0e218166f568b5a7a070dc3d62c0a63aca1c4d", size = 217350, upload-time = "2025-07-24T16:50:50.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/40/eefc3ebb9e458e3dc5db00e6b838969375577a09a8a39986d79cfa283175/coverage-7.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c16bbb661a7b4dafac0ab69e44d6dbcc6a64c4d93aefd89edc6f8911b6ab4a", size = 218235, upload-time = "2025-07-24T16:50:52.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/b8/3b53890c3ad52279eaea594a86bceaf04fcc0aed16856ff81531f75735f4/coverage-7.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:14e7c23fcb74ed808efb4eb48fcd25a759f0e20f685f83266d1df174860e4733", size = 216668, upload-time = "2025-07-24T16:50:53.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b4/7b419bb368c9f0b88889cb24805164f6e5550d7183fb59524f6173e0cf0b/coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df", size = 215124, upload-time = "2025-07-24T16:50:55.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/15/d862a806734c7e50fd5350cef18e22832ba3cdad282ca5660d6fd49def92/coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6", size = 215364, upload-time = "2025-07-24T16:50:57.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/93/4671ca5b2f3650c961a01252cbad96cb41f7c0c2b85c6062f27740a66b06/coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee", size = 246369, upload-time = "2025-07-24T16:50:59.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/79/2ca676c712d0540df0d7957a4266232980b60858a7a654846af1878cfde0/coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4", size = 248798, upload-time = "2025-07-24T16:51:01.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c5/67e000b03ba5291f915ddd6ba7c3333e4fdee9ba003b914c8f8f2d966dfe/coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e", size = 250260, upload-time = "2025-07-24T16:51:02.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/196783c425b5633db5c789b02a023858377bd73e4db4c805c2503cc42bbf/coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03", size = 248171, upload-time = "2025-07-24T16:51:04.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/1f/bf86c75f42de3641b4bbeab9712ec2815a3a8f5939768077245a492fad9f/coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535", size = 246368, upload-time = "2025-07-24T16:51:06.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/95/bfc9a3abef0b160404438e82ec778a0f38660c66a4b0ed94d0417d4d2290/coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967", size = 247578, upload-time = "2025-07-24T16:51:07.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7e/4fb2a284d56fe2a3ba0c76806923014854a64e503dc8ce21e5a2e6497eea/coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84", size = 217521, upload-time = "2025-07-24T16:51:09.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/30/3ab51058b75e9931fc48594d79888396cf009910fabebe12a6a636ab7f9e/coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d", size = 218308, upload-time = "2025-07-24T16:51:11.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/34/2adc74fd132eaa1873b1688acb906b477216074ed8a37e90426eca6d2900/coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699", size = 216706, upload-time = "2025-07-24T16:51:12.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/a7/a47f64718c2229b7860a334edd4e6ff41ec8513f3d3f4246284610344392/coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d", size = 215143, upload-time = "2025-07-24T16:51:14.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/86/14d76a409e9ffab10d5aece73ac159dbd102fc56627e203413bfc6d53b24/coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586", size = 215401, upload-time = "2025-07-24T16:51:15.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/b3/fb5c28148a19035a3877fac4e40b044a4c97b24658c980bcf7dff18bfab8/coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4", size = 245949, upload-time = "2025-07-24T16:51:17.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/95/357559ecfe73970d2023845797361e6c2e6c2c05f970073fff186fe19dd7/coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6", size = 248295, upload-time = "2025-07-24T16:51:19.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/58/bac5bc43085712af201f76a24733895331c475e5ddda88ac36c1332a65e6/coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32", size = 249733, upload-time = "2025-07-24T16:51:21.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/db/104b713b3b74752ee365346677fb104765923982ae7bd93b95ca41fe256b/coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd", size = 247943, upload-time = "2025-07-24T16:51:23.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4f/bef25c797c9496cf31ae9cfa93ce96b4414cacf13688e4a6000982772fd5/coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c", size = 245914, upload-time = "2025-07-24T16:51:24.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/6b/b3efa0b506dbb9a37830d6dc862438fe3ad2833c5f889152bce24d9577cf/coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88", size = 247296, upload-time = "2025-07-24T16:51:26.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/aa/95a845266aeacab4c57b08e0f4e0e2899b07809a18fd0c1ddef2ac2c9138/coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0", size = 217566, upload-time = "2025-07-24T16:51:28.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d1/27b6e5073a8026b9e0f4224f1ac53217ce589a4cdab1bee878f23bff64f0/coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82", size = 218337, upload-time = "2025-07-24T16:51:31.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/06/0e3ba498b11e2245fd96bd7e8dcdf90e1dd36d57f49f308aa650ff0561b8/coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957", size = 216740, upload-time = "2025-07-24T16:51:33.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8b/11529debbe3e6b39ef6e7c8912554724adc6dc10adbb617a855ecfd387eb/coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3", size = 215866, upload-time = "2025-07-24T16:51:35.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6d/d8981310879e395f39af66536665b75135b1bc88dd21c7764e3340e9ce69/coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458", size = 216083, upload-time = "2025-07-24T16:51:36.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/84/93295402de002de8b8c953bf6a1f19687174c4db7d44c1e85ffc153a772d/coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10", size = 257320, upload-time = "2025-07-24T16:51:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/5c/d0540db4869954dac0f69ad709adcd51f3a73ab11fcc9435ee76c518944a/coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157", size = 259182, upload-time = "2025-07-24T16:51:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b2/d7d57a41a15ca4b47290862efd6b596d0a185bfd26f15d04db9f238aa56c/coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0", size = 261322, upload-time = "2025-07-24T16:51:42.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/92/fd828ae411b3da63673305617b6fbeccc09feb7dfe397d164f55a65cd880/coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18", size = 258914, upload-time = "2025-07-24T16:51:44.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/49/4aa5f5464b2e1215640c0400c5b007e7f5cdade8bf39c55c33b02f3a8c7f/coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b", size = 257051, upload-time = "2025-07-24T16:51:45.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5a/ded2346098c7f48ff6e135b5005b97de4cd9daec5c39adb4ecf3a60967da/coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460", size = 257869, upload-time = "2025-07-24T16:51:47.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/66/e06cedb8fc7d1c96630b2f549b8cdc084e2623dcc70c900cb3b705a36a60/coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda", size = 218243, upload-time = "2025-07-24T16:51:49.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/1e/e84dd5ff35ed066bd6150e5c26fe0061ded2c59c209fd4f18db0650766c0/coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64", size = 219334, upload-time = "2025-07-24T16:51:50.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e0/b7b60b5dbc4e88eac0a0e9d5b4762409a59b29bf4e772b3509c8543ccaba/coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f", size = 217196, upload-time = "2025-07-24T16:51:52.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c1/597b4fa7d6c0861d4916c4fe5c45bf30c11b31a3b07fedffed23dec5f765/coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574", size = 215139, upload-time = "2025-07-24T16:51:54.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/47/07973dcad0161355cf01ff0023ab34466b735deb460a178f37163d7c800e/coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78", size = 215419, upload-time = "2025-07-24T16:51:56.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f8/c65127782da312084ef909c1531226c869bfe22dac8b92d9c609d8150131/coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842", size = 245917, upload-time = "2025-07-24T16:51:58.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/97/a7f2fe79b6ae759ccc8740608cf9686ae406cc5e5591947ebbf1d679a325/coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0", size = 248225, upload-time = "2025-07-24T16:51:59.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d3/d2e1496d7ac3340356c5de582e08e14b02933e254924f79d18e9749269d8/coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244", size = 249844, upload-time = "2025-07-24T16:52:01.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7e/e26d966c9cae62500e5924107974ede2e985f7d119d10ed44d102998e509/coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0", size = 247871, upload-time = "2025-07-24T16:52:03.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/95/6a372a292dfb9d6e2cc019fc50878f7a6a5fbe704604018d7c5c1dbffb2d/coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e", size = 245714, upload-time = "2025-07-24T16:52:05.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7f/63da22b7bc4e82e2c1df7755223291fc94fb01942cfe75e19f2bed96129e/coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5", size = 247131, upload-time = "2025-07-24T16:52:07.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/af/883272555e34872879f48daea4207489cb36df249e3069e6a8a664dc6ba6/coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017", size = 217804, upload-time = "2025-07-24T16:52:09.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/f6/7afc3439994b7f7311d858438d49eef8b06eadbf2322502d921a110fae1e/coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653", size = 218596, upload-time = "2025-07-24T16:52:11.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/99/7c715cfa155609ee3e71bc81b4d1265e1a9b79ad00cc3d19917ea736cbac/coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252", size = 216960, upload-time = "2025-07-24T16:52:12.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/18/5cb476346d3842f2e42cd92614a91921ebad38aa97aba63f2aab51919e35/coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50", size = 215881, upload-time = "2025-07-24T16:52:14.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/1b/c066d6836f4c1940a8df14894a5ec99db362838fdd9eee9fb7efe0e561d2/coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483", size = 216087, upload-time = "2025-07-24T16:52:16.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/57/f0996fd468e70d4d24d69eba10ecc2b913c2e85d9f3c1bb2075ad7554c05/coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570", size = 257408, upload-time = "2025-07-24T16:52:18.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/78/c9f308b2b986cc685d4964a3b829b053817a07d7ba14ff124cf06154402e/coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f", size = 259373, upload-time = "2025-07-24T16:52:20.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/13/192827b71da71255d3554cb7dc289bce561cb281bda27e1b0dd19d88e47d/coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06", size = 261495, upload-time = "2025-07-24T16:52:23.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/cf4694353405abbb440a94468df8e5c4dbf884635da1f056b43be7284d28/coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5", size = 258970, upload-time = "2025-07-24T16:52:25.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/83/fb45dac65c42eff6ce4153fe51b9f2a9fdc832ce57b7902ab9ff216c3faa/coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741", size = 257046, upload-time = "2025-07-24T16:52:27.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/95/577dc757c01f493a1951157475dd44561c82084387f12635974fb62e848c/coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a", size = 257946, upload-time = "2025-07-24T16:52:29.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5a/14b1be12e3a71fcf4031464ae285dab7df0939976236d0462c4c5382d317/coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104", size = 218602, upload-time = "2025-07-24T16:52:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/8d/c32890c0f4f7f71b8d4a1074ef8e9ef28e9b9c2f9fd0e2896f2cc32593bf/coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109", size = 219720, upload-time = "2025-07-24T16:52:34.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f7/e5cc13338aa5e2780b6226fb50e9bd8f3f88da85a4b2951447b4b51109a4/coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426", size = 217374, upload-time = "2025-07-24T16:52:36.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/fb/ace937cb8faf4d723bfc6058fee39b6756d888cf7524559885e437d06d71/coverage-7.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf283ec9c6878826291b17442eb5c32d3d252dc77d25e082b460b2d2ea67ba3c", size = 214811, upload-time = "2025-07-24T16:52:38.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/76/cbacf622916d4d3e1c5dbe07cacfdf19c80dfab9e5f65fa62d8fa0dbab31/coverage-7.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a83488c9fc6fff487f2ab551f9b64c70672357b8949f0951b0cd778b3ed8165", size = 215190, upload-time = "2025-07-24T16:52:40.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/24/794bebf18d9b6eb83defcc33b54c3af9ae781d2584aa07539631de2a4975/coverage-7.10.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b86df3a7494d12338c11e59f210a0498d6109bbc3a4037f44de517ebb30a9c6b", size = 241262, upload-time = "2025-07-24T16:52:42.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/49/674dfe9a00de71576d21825fb4c608db18ad69bec3e1184bf0b4d6e440c0/coverage-7.10.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6de9b460809e5e4787b742e786a36ae2346a53982e2be317cdcb7a33c56412fb", size = 243159, upload-time = "2025-07-24T16:52:44.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0c/ff37bcbae61f0e7783a2b58019e757e368754819f24428beebb31a9589e9/coverage-7.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de5ef8a5954d63fa26a6aaa4600e48f885ce70fe495e8fce2c43aa9241fc9434", size = 244727, upload-time = "2025-07-24T16:52:46.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/a42496f920770374a4116ccd01349d112e01969aeb03ba6eb3af74d5b7a0/coverage-7.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f178fe5e96f1e057527d5d0b20ab76b8616e0410169c33716cc226118eaf2c4f", size = 242662, upload-time = "2025-07-24T16:52:49.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f0/518341fbed44ada9660d92bb7001d848d6901d606f157d1d9009b36bfe1b/coverage-7.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a38c42f0182a012fa9ec25bc6057e51114c1ba125be304f3f776d6d283cb303", size = 240896, upload-time = "2025-07-24T16:52:51.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/08/fbe01e9a7394e11215ec3c67d51c66947abb4a02c9076cd04e8ccd454fa5/coverage-7.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bf09beb5c1785cb36aad042455c0afab561399b74bb8cdaf6e82b7d77322df99", size = 241848, upload-time = "2025-07-24T16:52:53.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d4/edf9d75080254d969e2a8c8b4f4a5391865a3097de493a2ad3c938c8c9d3/coverage-7.10.0-cp39-cp39-win32.whl", hash = "sha256:cb8dfbb5d3016cb8d1940444c0c69b40cdc6c8bde724b07716ee5ea47b5273c6", size = 217320, upload-time = "2025-07-24T16:52:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/bb/4ffaec3b62fa24faf4c462cbdb0145a395f532aacc85f2e51a571d54a74f/coverage-7.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:58ff22653cd93d563110d1ff2aef958f5f21be9e917762f8124d0e36f80f172a", size = 218215, upload-time = "2025-07-24T16:52:57.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/df/7c34bada8ace39f688b3bd5bc411459a20a3204ccb0984c90169a80a9366/coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f", size = 206777, upload-time = "2025-07-24T16:52:59.009Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfeature-sdk"
|
||||
version = "0.8.1"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "behave" },
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "behave" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=6.5" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-type"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "parse" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/e9/a3b2ae5f8a852542788ac1f1865dcea0c549cc40af243f42cabfa0acf24d/parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6", size = 96480, upload-time = "2024-10-03T11:51:00.353Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442, upload-time = "2024-10-03T11:50:58.519Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.32.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" },
|
||||
]
|
Loading…
Reference in New Issue