diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..fbfd088 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +max-complexity = 10 +exclude = .git,__pycache__,.venv diff --git a/.github/workflows/add-issues-to-engineering-project.yml b/.github/workflows/add-issues-to-engineering-project.yml new file mode 100644 index 0000000..0e820de --- /dev/null +++ b/.github/workflows/add-issues-to-engineering-project.yml @@ -0,0 +1,31 @@ +name: Add New Issue to Engineering Project + +on: + issues: + types: [opened] + +jobs: + add_issue_to_project: + runs-on: ubuntu-latest + steps: + - name: Add issue to project + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN_ADD_TO_ENG_PROJECT }} + script: | + const issueId = context.payload.issue.node_id; + + const mutation = ` + mutation { + addProjectV2ItemById(input: { + projectId: "${{ vars.ENGINEERING_PROJECT_ID }}", + contentId: "${issueId}" + }) { + item { + id + } + } + } + `; + + await github.graphql(mutation); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4088886 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,54 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +env: + tag_name: ${{ github.event.release.tag_name || github.ref_name }} + artifact-name: flagsmith-python-client-${{ github.event.release.tag_name || github.ref_name }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v4 + with: + python-version: 3.12 + + - name: Install Poetry + run: pipx install poetry + + - name: Build Package + run: poetry build + + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.artifact-name }} + path: dist/ + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.tag_name }} + files: dist/* + + publish: + runs-on: ubuntu-latest + needs: build + + permissions: + id-token: write # for pypa/gh-action-pypi-publish to authenticate with PyPI + + steps: + - uses: actions/download-artifact@v4 + with: + name: ${{ env.artifact-name }} + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 93d8009..9699bea 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,38 +1,39 @@ -name: Pytest and Black formatting +name: Tests on: - - pull_request - - push + pull_request: + push: + branches: + - main jobs: - test: - runs-on: ubuntu-latest - name: Pytest and Black formatting - - strategy: - max-parallel: 4 - matrix: - python-version: [3.6, 3.7, 3.8, 3.9] - - steps: - - name: Cloning repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt - - - name: Check Formatting - run: black --check . - - - name: Run Tests - run: | - pytest \ No newline at end of file + test: + runs-on: ubuntu-latest + + strategy: + max-parallel: 5 + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Cloning repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry install --with dev + + - name: Check for new typing errors + run: poetry run mypy --strict . + + - name: Run Tests + run: poetry run pytest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..968c75b --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: Update release PR + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.RELEASE_PLEASE_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d518665..a6a5c83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ .venv +.direnv/ *.pyc @@ -13,3 +14,5 @@ flagsmith.egg-info/ .envrc .tool-versions + +.coverage diff --git a/.isort.cfg b/.isort.cfg index 12a52aa..76e57bc 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -3,4 +3,4 @@ use_parentheses=true multi_line_output=3 include_trailing_comma=true line_length=79 -known_third_party = pytest,requests,requests_futures,setuptools +known_third_party = flag_engine,flask,pytest,requests,requests_futures,responses,urllib3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a7503d..3680bcf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,25 @@ repos: - - repo: https://github.com/asottile/seed-isort-config - rev: v1.9.3 - hooks: - - id: seed-isort-config - - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + - repo: https://github.com/PyCQA/isort + rev: 7.0.0 hooks: - id: isort - - repo: https://github.com/psf/black - rev: stable + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.12.0 hooks: - id: black - language_version: python3 + - repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + name: flake8 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier +ci: + autoupdate_commit_msg: 'ci: pre-commit autoupdate' diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d60e431 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,21 @@ +{ + "proseWrap": "always", + "singleQuote": true, + "printWidth": 120, + "trailingComma": "all", + "tabWidth": 4, + "overrides": [ + { + "files": "**/*.md", + "options": { + "tabWidth": 1 + } + }, + { + "files": ["**/*.yml", "**/*.yaml"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..3229161 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "5.1.1" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1e93a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,488 @@ +# Changelog + +## [5.1.1](https://github.com/Flagsmith/flagsmith-python-client/compare/v5.1.0...v5.1.1) (2026-01-05) + +### Bug Fixes + +- use-typing-extension ([#188](https://github.com/Flagsmith/flagsmith-python-client/issues/188)) + ([a636536](https://github.com/Flagsmith/flagsmith-python-client/commit/a636536162f94589bd7337090dad493fb3745ef0)) + +## [5.1.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v5.0.3...v5.1.0) (2026-01-05) + +### Features + +- added-trait-model-type ([#186](https://github.com/Flagsmith/flagsmith-python-client/issues/186)) + ([5c865ce](https://github.com/Flagsmith/flagsmith-python-client/commit/5c865cefd90d0ab8dc139e5d608809c3d03105a7)) + +### CI + +- pre-commit autoupdate ([#180](https://github.com/Flagsmith/flagsmith-python-client/issues/180)) + ([cafb3e2](https://github.com/Flagsmith/flagsmith-python-client/commit/cafb3e209491afa66f9b84e6b8aeb81c2b220a07)) +- pre-commit autoupdate ([#185](https://github.com/Flagsmith/flagsmith-python-client/issues/185)) + ([ef13716](https://github.com/Flagsmith/flagsmith-python-client/commit/ef13716013313ab5eaaaf4de3ca577707ea82a75)) + +### Other + +- **deps:** bump urllib3 from 2.5.0 to 2.6.0 ([#184](https://github.com/Flagsmith/flagsmith-python-client/issues/184)) + ([efd43fd](https://github.com/Flagsmith/flagsmith-python-client/commit/efd43fd01d0b591ee3583a3ebf13846cc129f5e9)) + +## [5.0.3](https://github.com/Flagsmith/flagsmith-python-client/compare/v5.0.2...v5.0.3) (2025-11-25) + +### Bug Fixes + +- `get_environment_flags` includes segments in evaluation context + ([#179](https://github.com/Flagsmith/flagsmith-python-client/issues/179)) + ([470c4e3](https://github.com/Flagsmith/flagsmith-python-client/commit/470c4e3e71be55795387ab023b4fe6f7623d6aec)) + +### Dependency Updates + +- Bump `flagsmith-flag-engine` to 10.0.3 ([#182](https://github.com/Flagsmith/flagsmith-python-client/issues/182)) + ([cf54be5](https://github.com/Flagsmith/flagsmith-python-client/commit/cf54be555b3d3178cd1494e8fc775b0283ad1ee9)) + +### Other + +- Standardize engine metadata ([#177](https://github.com/Flagsmith/flagsmith-python-client/issues/177)) + ([c0d57ec](https://github.com/Flagsmith/flagsmith-python-client/commit/c0d57ec4cd208b79908298e6cf9a3504e5d42fda)) + +## [5.0.2](https://github.com/Flagsmith/flagsmith-python-client/compare/v5.0.1...v5.0.2) (2025-10-30) + +### Dependency Updates + +- Bump `flagsmith-flag-engine` to 10.0.2 ([#173](https://github.com/Flagsmith/flagsmith-python-client/issues/173)) + ([9a0fc58](https://github.com/Flagsmith/flagsmith-python-client/commit/9a0fc58dc4e7eb590271a76e62a29078cf54b01b)) + +## [5.0.1](https://github.com/Flagsmith/flagsmith-python-client/compare/v5.0.0...v5.0.1) (2025-10-28) + +### Bug Fixes + +- TypeError: 'NoneType' object is not subscriptable (Python 3.11) + ([#170](https://github.com/Flagsmith/flagsmith-python-client/issues/170)) + ([c20f543](https://github.com/Flagsmith/flagsmith-python-client/commit/c20f54385390677657edcafc8c38204c5197e736)) +- ValueError: Invalid isoformat string on Python 3.10 + ([#168](https://github.com/Flagsmith/flagsmith-python-client/issues/168)) + ([29dee4d](https://github.com/Flagsmith/flagsmith-python-client/commit/29dee4de219754ec9874e2db39287f065b2dc166)) + +### Dependency Updates + +- Bump `flagsmith-flag-engine` to 10.0.1 ([#171](https://github.com/Flagsmith/flagsmith-python-client/issues/171)) + ([ab093bd](https://github.com/Flagsmith/flagsmith-python-client/commit/ab093bd71bf870461a153864eb2b9ea08533f852)) + +## [5.0.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v4.0.1...v5.0.0) (2025-10-24) + +### ⚠ BREAKING CHANGES + +- Restore v3 `OfflineHandler` interface ([#162](https://github.com/Flagsmith/flagsmith-python-client/issues/162)) + +### Features + +- Restore v3 `OfflineHandler` interface ([#162](https://github.com/Flagsmith/flagsmith-python-client/issues/162)) + ([374e292](https://github.com/Flagsmith/flagsmith-python-client/commit/374e29293aca44eafadda672907d9b701b8414fc)) +- Support feature metadata ([#163](https://github.com/Flagsmith/flagsmith-python-client/issues/163)) + ([1bbbdf8](https://github.com/Flagsmith/flagsmith-python-client/commit/1bbbdf8d98054ea4317a1ba3bd95f437a7edbf0e)) +- Support variant priority ([#161](https://github.com/Flagsmith/flagsmith-python-client/issues/161)) + ([4f84044](https://github.com/Flagsmith/flagsmith-python-client/commit/4f84044ea87a9d284f6731ff4cfe4835d5f99fa4)) + +### Bug Fixes + +- `get_identity_segments` tries to return identity override segments + ([#159](https://github.com/Flagsmith/flagsmith-python-client/issues/159)) + ([68d44a1](https://github.com/Flagsmith/flagsmith-python-client/commit/68d44a15feae75905d08103ff8dba53c605377fd)) + +### CI + +- pre-commit autoupdate ([#158](https://github.com/Flagsmith/flagsmith-python-client/issues/158)) + ([e2fe6eb](https://github.com/Flagsmith/flagsmith-python-client/commit/e2fe6eb01ba61a2477d54e75abc636c00c7f1e10)) + +### Dependency Updates + +- Bump `flagsmith-flag-engine` to 10.0.0 ([#165](https://github.com/Flagsmith/flagsmith-python-client/issues/165)) + ([68c40b5](https://github.com/Flagsmith/flagsmith-python-client/commit/68c40b554d4cbc9d7e3b5e31f7238de253648db1)) +- bump urllib3 from 2.2.3 to 2.5.0 ([#157](https://github.com/Flagsmith/flagsmith-python-client/issues/157)) + ([5de9650](https://github.com/Flagsmith/flagsmith-python-client/commit/5de965027d94cd4cd62178615c7e6d14c5340b70)) + +## [4.0.1](https://github.com/Flagsmith/flagsmith-python-client/compare/v4.0.0...v4.0.1) (2025-09-19) + +### Bug Fixes + +- Environment name not mapped to evaluation context + ([#153](https://github.com/Flagsmith/flagsmith-python-client/issues/153)) + ([3fcae7c](https://github.com/Flagsmith/flagsmith-python-client/commit/3fcae7c13fb36428ad8137195916f102e39e9453)) +- Feature state `django_id` fields are not handled + ([#156](https://github.com/Flagsmith/flagsmith-python-client/issues/156)) + ([860b630](https://github.com/Flagsmith/flagsmith-python-client/commit/860b6303cb310166d4bc740fcd8213f4c92164dd)) + +## [4.0.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.10.1...v4.0.0) (2025-09-02) + +### ⚠ BREAKING CHANGES + +- Engine V7 compatibility ([#150](https://github.com/Flagsmith/flagsmith-python-client/issues/150)) + +### Features + +- Engine V7 compatibility ([#150](https://github.com/Flagsmith/flagsmith-python-client/issues/150)) + ([5ecb078](https://github.com/Flagsmith/flagsmith-python-client/commit/5ecb0788b6c2903826210e0c453d68769220d250)) +- Standardised `User-Agent` ([#152](https://github.com/Flagsmith/flagsmith-python-client/issues/152)) + ([a0e96c9](https://github.com/Flagsmith/flagsmith-python-client/commit/a0e96c907f3401eb9fd801af05400e4ce92f8feb)) + +### Other + +- Add `CODEOWNERS` ([#148](https://github.com/Flagsmith/flagsmith-python-client/issues/148)) + ([a55a921](https://github.com/Flagsmith/flagsmith-python-client/commit/a55a92136699af06390a6570850d45464dcad7aa)) + +## [3.10.1](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.10.0...v3.10.1) (2025-08-21) + +### CI + +- pre-commit autoupdate ([#137](https://github.com/Flagsmith/flagsmith-python-client/issues/137)) + ([0372818](https://github.com/Flagsmith/flagsmith-python-client/commit/0372818c9717021c583b561816f54d62eb8be88e)) + +### Other + +- replacing-deprecated-methods ([#143](https://github.com/Flagsmith/flagsmith-python-client/issues/143)) + ([03715ab](https://github.com/Flagsmith/flagsmith-python-client/commit/03715abc403eeface2bd4d3d472b6da97d7d0e77)) + +## [3.10.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.9.2...v3.10.0) (2025-08-06) + +### Features + +- Support SDK metrics ([#136](https://github.com/Flagsmith/flagsmith-python-client/issues/136)) + ([441c46a](https://github.com/Flagsmith/flagsmith-python-client/commit/441c46a0ae72f1ecb8d6e0b4b82f24706c34f942)) + +### Other + +- bump-flagsmith-engine-version ([#139](https://github.com/Flagsmith/flagsmith-python-client/issues/139)) + ([2cd2435](https://github.com/Flagsmith/flagsmith-python-client/commit/2cd2435d4f3872ea399ebeacadaee8c943707a1a)) + +## [3.9.2](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.9.1...v3.9.2) (2025-07-08) + +### CI + +- pre-commit autoupdate ([#128](https://github.com/Flagsmith/flagsmith-python-client/issues/128)) + ([62c55a9](https://github.com/Flagsmith/flagsmith-python-client/commit/62c55a996c5b3929ff1a710b95d1289482d85cd3)) + +### Docs + +- removing hero image from SDK readme ([#131](https://github.com/Flagsmith/flagsmith-python-client/issues/131)) + ([80af1d8](https://github.com/Flagsmith/flagsmith-python-client/commit/80af1d8d4cc848029963d1f5fc55fe1e0feced0e)) + +### Other + +- **actions:** Move project id to a var ([#126](https://github.com/Flagsmith/flagsmith-python-client/issues/126)) + ([3bd7943](https://github.com/Flagsmith/flagsmith-python-client/commit/3bd7943439e880b270d7c1303f1606c9504cf402)) +- Add workflow to add new issues to engineering project + ([#124](https://github.com/Flagsmith/flagsmith-python-client/issues/124)) + ([b67f9a6](https://github.com/Flagsmith/flagsmith-python-client/commit/b67f9a6c13467c12b476093362a8606b978f7456)) +- **ci:** show all sections in release please config + ([#132](https://github.com/Flagsmith/flagsmith-python-client/issues/132)) + ([e684919](https://github.com/Flagsmith/flagsmith-python-client/commit/e684919ba7a8ab93de0fc8b7214d1e8abe664157)) +- **deps:** bump requests from 2.32.3 to 2.32.4 + ([#129](https://github.com/Flagsmith/flagsmith-python-client/issues/129)) + ([3019636](https://github.com/Flagsmith/flagsmith-python-client/commit/30196369bcfb55647f1456daee9e4f6da49963a7)) +- **deps:** update flagsmith-flag-engine ([#133](https://github.com/Flagsmith/flagsmith-python-client/issues/133)) + ([bfcd454](https://github.com/Flagsmith/flagsmith-python-client/commit/bfcd454851a2b7b772e7937b94ccf4b1cdaba401)) + +## [3.9.1](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.9.0...v3.9.1) (2025-04-29) + +### Bug Fixes + +- Fix HTTP requests not timing out + ([7d61a47](https://github.com/Flagsmith/flagsmith-python-client/commit/7d61a47d0e7ec500b77bec15403f655a159e01fa)) + +## [3.9.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.8.0...v3.9.0) (2025-04-01) + +### Features + +- Add utility functions for webhooks + ([3d8df11](https://github.com/Flagsmith/flagsmith-python-client/commit/3d8df11ddf4656c5f20c0f558e1d7a3af412b960)) + +## [3.8.0](https://github.com/Flagsmith/flagsmith-python-client/compare/v3.7.0...v3.8.0) (2024-08-12) + +### Features + +- Support transient identities and traits ([#93](https://github.com/Flagsmith/flagsmith-python-client/issues/93)) + ([0a11db5](https://github.com/Flagsmith/flagsmith-python-client/commit/0a11db5a1010c177856716e6b90292651fa5889b)) + +### Bug Fixes + +- Flaky `test_offline_mode__local_evaluation__correct_fallback` + ([#103](https://github.com/Flagsmith/flagsmith-python-client/issues/103)) + ([a2136d7](https://github.com/Flagsmith/flagsmith-python-client/commit/a2136d7cb73e819da8a7a08ab98a3c7bfaa52df9)) +- Offline handler not used as fallback for local evaluation mode during init + ([#100](https://github.com/Flagsmith/flagsmith-python-client/issues/100)) + ([6f6d595](https://github.com/Flagsmith/flagsmith-python-client/commit/6f6d5950bc3a6befd953dc1a24ef497a4a018c7b)) +- Package version not bumped during automatic release + ([#102](https://github.com/Flagsmith/flagsmith-python-client/issues/102)) + ([840bc0e](https://github.com/Flagsmith/flagsmith-python-client/commit/840bc0e33803a66af2342ec7ff0053744ada603d)) + +## [v3.7.0](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.7.0) - 17 Jul 2024 + +### What's Changed + +- Bump black from 23.12.1 to 24.3.0 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/81 +- chore: update github actions by [@dabeeeenster](https://github.com/dabeeeenster) in + https://github.com/Flagsmith/flagsmith-python-client/pull/82 +- Remove pytz and replace usage with core python modules by [@MerijnBol](https://github.com/MerijnBol) in + https://github.com/Flagsmith/flagsmith-python-client/pull/80 +- docs: misc README improvements by [@rolodato](https://github.com/rolodato) in + https://github.com/Flagsmith/flagsmith-python-client/pull/84 +- Bump idna from 3.6 to 3.7 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/83 +- Bump requests from 2.31.0 to 2.32.0 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/85 +- Bump urllib3 from 2.2.1 to 2.2.2 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/87 +- Bump certifi from 2024.2.2 to 2024.7.4 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/89 +- Bump setuptools from 69.1.1 to 70.0.0 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/90 +- fix: Add a custom exception for invalid features by [@tushar5526](https://github.com/tushar5526) in + https://github.com/Flagsmith/flagsmith-python-client/pull/86 +- chore: Bump package, fix README by [@khvn26](https://github.com/khvn26) in + https://github.com/Flagsmith/flagsmith-python-client/pull/92 + +### New Contributors + +- [@MerijnBol](https://github.com/MerijnBol) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/80 +- [@rolodato](https://github.com/rolodato) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/84 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.6.0...v3.7.0 + +[Changes][v3.7.0] + + + +## [v3.6.0](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.6.0) - 14 Mar 2024 + +### What's Changed + +- [##61](https://github.com/Flagsmith/flagsmith-python-client/issues/61) SSE streaming manager by + [@bne](https://github.com/bne) in https://github.com/Flagsmith/flagsmith-python-client/pull/73 +- chore: remove examples by [@dabeeeenster](https://github.com/dabeeeenster) in + https://github.com/Flagsmith/flagsmith-python-client/pull/75 +- feat: Add identity overrides to local evaluation mode by [@khvn26](https://github.com/khvn26) in + https://github.com/Flagsmith/flagsmith-python-client/pull/72 +- feat: strict typing by [@tushar5526](https://github.com/tushar5526) in + https://github.com/Flagsmith/flagsmith-python-client/pull/70 and + https://github.com/Flagsmith/flagsmith-python-client/pull/71 +- ci: run pytest on push to main by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/78 +- fix: Set the environment for local evaluation mode on init by [@zachaysan](https://github.com/zachaysan) in + https://github.com/Flagsmith/flagsmith-python-client/pull/76 +- chore: version bump to 3.6.0 by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/79 + +### New Contributors + +- [@tushar5526](https://github.com/tushar5526) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/71 +- [@bne](https://github.com/bne) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/73 +- [@zachaysan](https://github.com/zachaysan) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/76 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.5.0...v3.6.0 + +[Changes][v3.6.0] + + + +## [Version 3.5.0 (v3.5.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.5.0) - 23 Nov 2023 + +### Compatibility Notes + +Flagsmith Python SDK 3.5.0 brings the new version of Flagsmith engine that depends on Pydantic V2. If you're still using +Pydantic V1 in your project, consider doing one of the following: + +- Change your `pydantic` imports to `pydantic.v1`. +- Use the [bump-pydantic](https://github.com/pydantic/bump-pydantic) tool to migrate your code semi-automatically. + +Refer to the [Pydantic V2 migration guide](https://docs.pydantic.dev/latest/migration/) for more info. + +### What's Changed + +- Bump `flagsmith-flag-engine` to 5.0.0 by [@khvn26](https://github.com/khvn26) in + https://github.com/Flagsmith/flagsmith-python-client/pull/69 +- Ensure polling thread is resilient to errors and exceptions by [@goncalossilva](https://github.com/goncalossilva) in + https://github.com/Flagsmith/flagsmith-python-client/pull/60 +- Bump certifi from 2023.5.7 to 2023.7.22 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/56 +- Bump urllib3 from 2.0.4 to 2.0.7 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/64 + +### New Contributors + +- [@goncalossilva](https://github.com/goncalossilva) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/60 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.4.0...v3.5.0 + +[Changes][v3.5.0] + + + +## [Version 3.4.0 (v3.4.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.4.0) - 31 Jul 2023 + +### What's Changed + +- Implementation of offline mode (single client class) by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/50 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.3.0...v3.4.0 + +[Changes][v3.4.0] + + + +## [Version 3.3.0 (v3.3.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.3.0) - 27 Jul 2023 + +### What's Changed + +- Update flag-engine by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/49 + +### New Contributors + +- [@khvn26](https://github.com/khvn26) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/52 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.2...v3.3.0 + +[Changes][v3.3.0] + + + +## [Version 3.2.2 (v3.2.2)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.2.2) - 07 Jul 2023 + +### What's Changed + +- Use daemon argument to ensure that polling manager is killed by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/47 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.1...v3.2.2 + +[Changes][v3.2.2] + + + +## [Version 3.2.1 (v3.2.1)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.2.1) - 19 May 2023 + +### What's Changed + +- Bump flask from 2.0.2 to 2.2.5 in /example by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/44 +- Bump certifi from 2021.10.8 to 2022.12.7 by [@dependabot](https://github.com/dependabot) in + https://github.com/Flagsmith/flagsmith-python-client/pull/36 +- improvement/general-housekeeping by [@dabeeeenster](https://github.com/dabeeeenster) in + https://github.com/Flagsmith/flagsmith-python-client/pull/43 +- chore/bump-version by [@dabeeeenster](https://github.com/dabeeeenster) in + https://github.com/Flagsmith/flagsmith-python-client/pull/45 + +### New Contributors + +- [@dependabot](https://github.com/dependabot) made their first contribution in + https://github.com/Flagsmith/flagsmith-python-client/pull/44 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.0...v3.2.1 + +[Changes][v3.2.1] + + + +## [Version 3.2.0 (v3.2.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.2.0) - 13 Jan 2023 + +### What's Changed + +- Add proxies option to Flagsmith by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/39 +- Release 3.2.0 by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/38 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.1.0...v3.2.0 + +[Changes][v3.2.0] + + + +## [Version 3.1.0 (v3.1.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.1.0) - 01 Nov 2022 + +### What's Changed + +- Upgrade engine (2.3.0) by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/34 +- Release 3.1.0 by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/33 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.1...v3.1.0 + +[Changes][v3.1.0] + + + +## [Version 3.0.1 (v3.0.1)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.0.1) - 13 Jul 2022 + +### What's Changed + +- Use feature name instead of feature id by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/29 +- Release 3.0.1 by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/30 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.0...v3.0.1 + +[Changes][v3.0.1] + + + +## [Version 3.0.0 (v3.0.0)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.0.0) - 07 Jun 2022 + +### What's Changed + +- Feature/rewrite for client side eval by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/17 +- Refactor default flag logic by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/22 +- Expose segments by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/24 +- Prevent initialisation with local evaluation without server key by [@matthewelwell](https://github.com/matthewelwell) + in https://github.com/Flagsmith/flagsmith-python-client/pull/25 +- Update default url to point to edge by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/27 +- Release 3.0.0 by [@matthewelwell](https://github.com/matthewelwell) in + https://github.com/Flagsmith/flagsmith-python-client/pull/16 + +**Full Changelog**: https://github.com/Flagsmith/flagsmith-python-client/compare/v1.0.1...v3.0.0 + +[Changes][v3.0.0] + + + +## [Version 3.0.0 alpha 2 (v3.0.0-alpha.2)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.0.0-alpha.2) - 30 May 2022 + +[Changes][v3.0.0-alpha.2] + + + +## [Version 3.0.0 - Alpha 1 (v3.0.0-alpha.1)](https://github.com/Flagsmith/flagsmith-python-client/releases/tag/v3.0.0-alpha.1) - 17 May 2022 + +First alpha release of v3.0.0 + +[Changes][v3.0.0-alpha.1] + +[v3.7.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.6.0...v3.7.0 +[v3.6.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.5.0...v3.6.0 +[v3.5.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.4.0...v3.5.0 +[v3.4.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.3.0...v3.4.0 +[v3.3.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.2...v3.3.0 +[v3.2.2]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.1...v3.2.2 +[v3.2.1]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.2.0...v3.2.1 +[v3.2.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.1.0...v3.2.0 +[v3.1.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.1...v3.1.0 +[v3.0.1]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.0...v3.0.1 +[v3.0.0]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.0-alpha.2...v3.0.0 +[v3.0.0-alpha.2]: https://github.com/Flagsmith/flagsmith-python-client/compare/v3.0.0-alpha.1...v3.0.0-alpha.2 +[v3.0.0-alpha.1]: https://github.com/Flagsmith/flagsmith-python-client/tree/v3.0.0-alpha.1 + + diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..1cc6b53 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @flagsmith/flagsmith-back-end diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..87bd0e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Contributing + +We're always looking to improve this project, open source contribution is encouraged so long as they adhere to these +guidelines. + +## Pull Requests + +The Flagsmith team will be monitoring for pull requests. When we get one, a member of team will test the work against +our internal uses and sign off on the changes. From here, we'll either merge the pull request or provide feedback +suggesting the next steps. + +### A couple things to keep in mind + +- If you've changed APIs, update the documentation. +- Keep the code style (indents, wrapping) consistent. +- If your PR involves a lot of commits, squash them using `git rebase -i` as this makes it easier for us to review. +- Keep lines under 80 characters. diff --git a/License b/LICENSE similarity index 54% rename from License rename to LICENSE index e57593a..1608283 100644 --- a/License +++ b/LICENSE @@ -1,12 +1,11 @@ -Copyright (c) 2017 Solid State Technology Ltd (https://www.solidstategroup.com/) and individual contributors. -All rights reserved. +Copyright 2022 Bullet Train Ltd. A UK company. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - 3. Neither the name of the Sentry nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..583de91 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Flagsmith Python SDK + +> Flagsmith allows you to manage feature flags and remote config across multiple projects, environments and +> organisations. + +The SDK for Python applications for [https://www.flagsmith.com/](https://www.flagsmith.com/). + +## Adding to your project + +For full documentation visit +[https://docs.flagsmith.com/clients/server-side?language=python](https://docs.flagsmith.com/clients/server-side?language=python). + +## Contributing + +Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull +requests + +## Getting Help + +If you encounter a bug or feature request we would like to hear about it. Before you submit an issue please search +existing issues in order to prevent duplicates. + +## Get in touch + +If you have any questions about our projects you can email +support@flagsmith.com. + +## Useful links + +[Website](https://www.flagsmith.com/) + +[Documentation](https://docs.flagsmith.com/) diff --git a/Readme.md b/Readme.md deleted file mode 100644 index b07e206..0000000 --- a/Readme.md +++ /dev/null @@ -1,29 +0,0 @@ - - -# Flagsmith Python SDK - -> Flagsmith allows you to manage feature flags and remote config across multiple projects, environments and organisations. - -The SDK for Python applications for [https://www.flagsmith.com/](https://www.flagsmith.com/). - -## Adding to your project - -For full documentation visit [https://docs.flagsmith.com/clients/python/](https://docs.flagsmith.com/clients/python/) - -## Contributing - -Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests - -## Getting Help - -If you encounter a bug or feature request we would like to hear about it. Before you submit an issue please search existing issues in order to prevent duplicates. - -## Get in touch - -If you have any questions about our projects you can email support@flagsmith.com. - -## Useful links - -[Website](https://www.flagsmith.com/) - -[Documentation](https://docs.flagsmith.com/) diff --git a/dependabot.yml b/dependabot.yml new file mode 100644 index 0000000..deb3bfc --- /dev/null +++ b/dependabot.yml @@ -0,0 +1,16 @@ +# $schema: https://json.schemastore.org/dependabot-2.0.json + +version: 2 +updates: + - package-ecosystem: 'pip' + # we only want security updates from dependabot, so we set the limit to 0 + # for regular updates. See documentation for further information here: + # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#open-pull-requests-limit- + open-pull-requests-limit: 0 + directory: '.' + schedule: + interval: 'daily' + reviewers: + - 'flagsmith/flagsmith-back-end' + commit-message: + prefix: 'deps' diff --git a/example/example.py b/example/example.py deleted file mode 100644 index e8e9c11..0000000 --- a/example/example.py +++ /dev/null @@ -1,58 +0,0 @@ -import json - -from flagsmith import Flagsmith - -api_key = input("Please provide an environment api key: ") - -flagsmith = Flagsmith(environment_id=api_key) - -identifier = input("Please provide an example identity: ") -feature_name = input("Please provide an example feature name: ") - -print_get_flags = input("Print result of get_flags for environment? (y/n) ") -if print_get_flags.lower() == "y": - print(json.dumps(flagsmith.get_flags(), indent=2)) - -print_get_flags_with_identity = input("Print result of get_flags with identity? (y/n) ") -if print_get_flags_with_identity.lower() == "y": - print(json.dumps(flagsmith.get_flags(identifier), indent=2)) - -print_get_flags_for_user = input("Print result of get_flags_for_user? (y/n) ") -if print_get_flags_for_user.lower() == "y": - print(json.dumps(flagsmith.get_flags_for_user(identifier), indent=2)) - -print_get_value_of_feature_for_environment = input( - "Print result of get_value for environment? (y/n) " -) -if print_get_value_of_feature_for_environment.lower() == "y": - print(flagsmith.get_value(feature_name)) - -print_get_value_of_feature_for_environment = input( - "Print result of get_value for identity? (y/n) " -) -if print_get_value_of_feature_for_environment.lower() == "y": - print(flagsmith.get_value(feature_name, identity=identifier)) - -print_result_of_has_feature = input("Print result of has feature? (y/n) ") -if print_result_of_has_feature.lower() == "y": - print(flagsmith.has_feature(feature_name)) - -print_result_of_feature_enabled_for_environment = input( - "Print result of feature_enabled for environment? (y/n) " -) -if print_result_of_feature_enabled_for_environment.lower() == "y": - print(flagsmith.feature_enabled(feature_name)) - -print_result_of_feature_enabled_for_identity = input( - "Print result of feature_enabled for identity? (y/n) " -) -if print_result_of_feature_enabled_for_identity.lower() == "y": - print(flagsmith.feature_enabled(feature_name, identity=identifier)) - -set_trait = input("Would you like to test traits? (y/n) ") -if set_trait.lower() == "y": - trait_key = input("Trait key: ") - trait_value = input("Trait value: ") - flagsmith.set_trait(trait_key, trait_value, identifier) - print("Trait set successfully") - print("Result from get_trait is %s" % flagsmith.get_trait(trait_key, identifier)) diff --git a/example/readme.md b/example/readme.md deleted file mode 100644 index 2a2300c..0000000 --- a/example/readme.md +++ /dev/null @@ -1,19 +0,0 @@ -# Flagsmith Basic Python Example - -To use this basic example, you'll need to first configure a project with at least one feature in Flagsmith. - -Once you've done this, you'll then need to install the latest version of the Flagsmith package by running: - -```bash -pip install flagsmith -``` - -Then you can run: - -```bash -python example.py -``` - -The script will grab some information from you such as the environment key to test with, an identifier and a feature -name. Once you've inputted those, the script will run you through all of the methods available in the Flagsmith -client and print the result. diff --git a/flagsmith/__init__.py b/flagsmith/__init__.py index b1da5ce..41473e4 100644 --- a/flagsmith/__init__.py +++ b/flagsmith/__init__.py @@ -1 +1,5 @@ -from .flagsmith import Flagsmith +from flagsmith import webhooks +from flagsmith.flagsmith import Flagsmith +from flagsmith.version import __version__ + +__all__ = ("Flagsmith", "webhooks", "__version__") diff --git a/flagsmith/analytics.py b/flagsmith/analytics.py index 5003699..dee1ed5 100644 --- a/flagsmith/analytics.py +++ b/flagsmith/analytics.py @@ -1,12 +1,13 @@ import json +import typing from datetime import datetime -from requests_futures.sessions import FuturesSession +from requests_futures.sessions import FuturesSession # type: ignore -ANALYTICS_ENDPOINT = "analytics/flags/" +ANALYTICS_ENDPOINT: typing.Final[str] = "analytics/flags/" # Used to control how often we send data(in seconds) -ANALYTICS_TIMER = 10 +ANALYTICS_TIMER: typing.Final[int] = 10 session = FuturesSession(max_workers=4) @@ -17,7 +18,9 @@ class AnalyticsProcessor: the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics. """ - def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3): + def __init__( + self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3 + ): """ Initialise the AnalyticsProcessor to handle sending analytics on flag usage to the Flagsmith API. @@ -30,11 +33,10 @@ def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3): self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT self.environment_key = environment_key self._last_flushed = datetime.now() - self.analytics_data = {} - self.timeout = timeout - super().__init__() + self.analytics_data: typing.MutableMapping[str, typing.Any] = {} + self.timeout = timeout or 3 - def flush(self): + def flush(self) -> None: """ Sends all the collected data to the api asynchronously and resets the timer """ @@ -54,7 +56,7 @@ def flush(self): self.analytics_data.clear() self._last_flushed = datetime.now() - def track_feature(self, feature_id: int): - self.analytics_data[feature_id] = self.analytics_data.get(feature_id, 0) + 1 + def track_feature(self, feature_name: str) -> None: + self.analytics_data[feature_name] = self.analytics_data.get(feature_name, 0) + 1 if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER: self.flush() diff --git a/example/__init__.py b/flagsmith/api/__init__.py similarity index 100% rename from example/__init__.py rename to flagsmith/api/__init__.py diff --git a/flagsmith/api/types.py b/flagsmith/api/types.py new file mode 100644 index 0000000..3656487 --- /dev/null +++ b/flagsmith/api/types.py @@ -0,0 +1,76 @@ +import typing + +from flag_engine.engine import ContextValue +from flag_engine.segments.types import ConditionOperator, RuleType +from typing_extensions import NotRequired, TypedDict + + +class SegmentConditionModel(typing.TypedDict): + operator: ConditionOperator + property_: str + value: str + + +class SegmentRuleModel(typing.TypedDict): + conditions: "list[SegmentConditionModel]" + rules: "list[SegmentRuleModel]" + type: RuleType + + +class SegmentModel(typing.TypedDict): + id: int + name: str + rules: list[SegmentRuleModel] + feature_states: "NotRequired[list[FeatureStateModel]]" + + +class ProjectModel(typing.TypedDict): + segments: list[SegmentModel] + + +class FeatureModel(typing.TypedDict): + id: int + name: str + + +class FeatureSegmentModel(typing.TypedDict): + priority: int + + +class MultivariateFeatureOptionModel(typing.TypedDict): + value: str + + +class MultivariateFeatureStateValueModel(typing.TypedDict): + id: typing.Optional[int] + multivariate_feature_option: MultivariateFeatureOptionModel + mv_fs_value_uuid: str + percentage_allocation: float + + +class FeatureStateModel(typing.TypedDict): + enabled: bool + feature_segment: NotRequired[typing.Optional[FeatureSegmentModel]] + feature_state_value: object + feature: FeatureModel + featurestate_uuid: str + multivariate_feature_state_values: list[MultivariateFeatureStateValueModel] + + +class IdentityModel(typing.TypedDict): + identifier: str + identity_features: list[FeatureStateModel] + + +class EnvironmentModel(typing.TypedDict): + api_key: str + feature_states: list[FeatureStateModel] + identity_overrides: list[IdentityModel] + name: str + project: ProjectModel + + +class TraitModel(TypedDict): + trait_key: str + trait_value: ContextValue + transient: NotRequired[bool] diff --git a/flagsmith/exceptions.py b/flagsmith/exceptions.py new file mode 100644 index 0000000..e499207 --- /dev/null +++ b/flagsmith/exceptions.py @@ -0,0 +1,10 @@ +class FlagsmithClientError(Exception): + pass + + +class FlagsmithAPIError(FlagsmithClientError): + pass + + +class FlagsmithFeatureDoesNotExistError(FlagsmithClientError): + pass diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 714ef3f..15da5ec 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -1,219 +1,445 @@ import logging +import typing +from datetime import datetime +from urllib.parse import urljoin import requests - -from .analytics import AnalyticsProcessor +from flag_engine import engine +from requests.adapters import HTTPAdapter +from urllib3 import Retry + +from flagsmith.analytics import AnalyticsProcessor +from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError +from flagsmith.mappers import ( + map_context_and_identity_data_to_context, + map_environment_document_to_context, + map_environment_document_to_environment_updated_at, + map_segment_results_to_identity_segments, +) +from flagsmith.models import DefaultFlag, Flags, Segment +from flagsmith.offline_handlers import OfflineHandler +from flagsmith.polling_manager import EnvironmentDataPollingManager +from flagsmith.streaming_manager import EventStreamManager +from flagsmith.types import ( + ApplicationMetadata, + JsonType, + SDKEvaluationContext, + StreamEvent, + TraitMapping, +) +from flagsmith.utils.identities import generate_identity_data +from flagsmith.version import __version__ logger = logging.getLogger(__name__) -SERVER_URL = "https://api.flagsmith.com/api/v1/" -FLAGS_ENDPOINT = "flags/" -IDENTITY_ENDPOINT = "identities/" -TRAIT_ENDPOINT = "traits/" +DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/" +DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/" +DEFAULT_USER_AGENT = f"flagsmith-python-sdk/{__version__}" class Flagsmith: + """A Flagsmith client. + + Provides an interface for interacting with the Flagsmith http API. + + Basic Usage:: + + >>> from flagsmith import Flagsmith + >>> flagsmith = Flagsmith(environment_key="") + >>> environment_flags = flagsmith.get_environment_flags() + >>> feature_enabled = environment_flags.is_feature_enabled("foo") + >>> identity_flags = flagsmith.get_identity_flags("identifier", {"foo": "bar"}) + >>> feature_enabled_for_identity = identity_flags.is_feature_enabled("foo") + """ + def __init__( - self, environment_id, api=SERVER_URL, custom_headers=None, request_timeout=None + self, + environment_key: typing.Optional[str] = None, + api_url: typing.Optional[str] = None, + realtime_api_url: typing.Optional[str] = None, + custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_timeout_seconds: typing.Optional[int] = 10, + enable_local_evaluation: bool = False, + environment_refresh_interval_seconds: typing.Union[int, float] = 60, + retries: typing.Optional[Retry] = None, + enable_analytics: bool = False, + default_flag_handler: typing.Optional[ + typing.Callable[[str], DefaultFlag] + ] = None, + proxies: typing.Optional[typing.Dict[str, str]] = None, + offline_mode: bool = False, + offline_handler: typing.Optional[OfflineHandler] = None, + enable_realtime_updates: bool = False, + application_metadata: typing.Optional[ApplicationMetadata] = None, ): """ - Initialise Flagsmith environment. - - :param environment_id: environment key obtained from the Flagsmith UI - :param api: (optional) api url to override when using self hosted version - :param custom_headers: (optional) dict which will be passed in headers for each api call - :param request_timeout: (optional) request timeout in seconds + :param environment_key: The environment key obtained from Flagsmith interface. + Required unless offline_mode is True. + :param api_url: Override the URL of the Flagsmith API to communicate with + :param realtime_api_url: Override the URL of the Flagsmith real-time API + :param custom_headers: Additional headers to add to requests made to the + Flagsmith API + :param request_timeout_seconds: Number of seconds to wait for a request to + complete before terminating the request + :param enable_local_evaluation: Enables local evaluation of flags + :param environment_refresh_interval_seconds: If using local evaluation, + specify the interval period between refreshes of local environment data + :param retries: a urllib3.Retry object to use on all http requests to the + Flagsmith API + :param enable_analytics: if enabled, sends additional requests to the Flagsmith + API to power flag analytics charts + :param default_flag_handler: callable which will be used in the case where + flags cannot be retrieved from the API or a non-existent feature is + requested + :param proxies: as per https://requests.readthedocs.io/en/latest/api/#requests.Session.proxies + :param offline_mode: sets the client into offline mode. Relies on offline_handler for + evaluating flags. + :param offline_handler: provide a handler for offline logic. Used to get environment + document from another source when in offline_mode. Works in place of + default_flag_handler if offline_mode is not set and using remote evaluation. + :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API + :param application_metadata: Optional metadata about the client application. """ - self.environment_id = environment_id - self.api = api - self.flags_endpoint = api + FLAGS_ENDPOINT - self.identities_endpoint = api + IDENTITY_ENDPOINT - self.traits_endpoint = api + TRAIT_ENDPOINT - self.custom_headers = custom_headers or {} - self.request_timeout = request_timeout - self._analytics_processor = AnalyticsProcessor( - environment_id, api, self.request_timeout - ) - - def get_flags(self, identity=None): - """ - Get all flags for the environment or optionally provide an identity within an environment - to get their flags. Will return overridden identity flags where given and fill in the gaps - with the default environment flags. + self.offline_mode = offline_mode + self.enable_local_evaluation = enable_local_evaluation + self.environment_refresh_interval_seconds = environment_refresh_interval_seconds + self.offline_handler = offline_handler + self.default_flag_handler = default_flag_handler + self.enable_realtime_updates = enable_realtime_updates + self._analytics_processor: typing.Optional[AnalyticsProcessor] = None + self._evaluation_context: typing.Optional[SDKEvaluationContext] = None + self._environment_updated_at: typing.Optional[datetime] = None + + # argument validation + if offline_mode and not offline_handler: + raise ValueError("offline_handler must be provided to use offline mode.") + elif default_flag_handler and offline_handler: + raise ValueError( + "Cannot use both default_flag_handler and offline_handler." + ) - :param identity: application's unique identifier for the user to check feature states - :return: list of dictionaries representing feature states for environment / identity - """ - if identity: - data = self._get_flags_response(identity=identity) - else: - data = self._get_flags_response() + if enable_realtime_updates and not enable_local_evaluation: + raise ValueError( + "Can only use realtime updates when running in local evaluation mode." + ) - if data: - return data - else: - logger.error("Failed to get flags for environment.") + if self.offline_handler: + self._evaluation_context = map_environment_document_to_context( + self.offline_handler.get_environment() + ) - def get_flags_for_user(self, identity): - """ - Get all flags for a user + if not self.offline_mode: + if not environment_key: + raise ValueError("environment_key is required.") - :param identity: application's unique identifier for the user to check feature states - :return: list of dictionaries representing identities feature states for environment - """ - return self.get_flags(identity=identity) + self.session = requests.Session() + self.session.headers.update( + self._get_headers( + environment_key=environment_key, + application_metadata=application_metadata, + custom_headers=custom_headers, + ) + ) + self.session.proxies.update(proxies or {}) + retries = retries or Retry(total=3, backoff_factor=0.1) - def has_feature(self, feature_name): - """ - Determine if given feature exists for an environment. + api_url = api_url or DEFAULT_API_URL + self.api_url = api_url if api_url.endswith("/") else f"{api_url}/" - :param feature_name: name of feature to test existence of - :return: True if exists, False if not. - """ - data = self._get_flags_response(feature_name) - if data: - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) - return True + realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL + self.realtime_api_url = ( + realtime_api_url + if realtime_api_url.endswith("/") + else f"{realtime_api_url}/" + ) - return False + self.request_timeout_seconds = request_timeout_seconds + self.session.mount(self.api_url, HTTPAdapter(max_retries=retries)) - def feature_enabled(self, feature_name, identity=None): - """ - Get enabled state of given feature for an environment. + self.environment_flags_url = urljoin(self.api_url, "flags/") + self.identities_url = urljoin(self.api_url, "identities/") + self.environment_url = urljoin(self.api_url, "environment-document/") - :param feature_name: name of feature to determine if enabled - :param identity: (optional) application's unique identifier for the user to check feature state - :return: True / False if feature exists. None otherwise. - """ - if not feature_name: - return None + if self.enable_local_evaluation: + if not environment_key.startswith("ser."): + raise ValueError( + "In order to use local evaluation, please generate a server key " + "in the environment settings page." + ) - data = self._get_flags_response(feature_name, identity) + self._initialise_local_evaluation() - if not data: - return None + if enable_analytics: + self._analytics_processor = AnalyticsProcessor( + environment_key, self.api_url, timeout=self.request_timeout_seconds + ) - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) + def _initialise_local_evaluation(self) -> None: + # To ensure that the environment is set before allowing subsequent + # method calls, update the environment manually. + self.update_environment() + if self.enable_realtime_updates: + if not self._evaluation_context: + raise ValueError("Unable to get environment from API key") + + stream_url = urljoin( + self.realtime_api_url, + f"sse/environments/{self._evaluation_context['environment']['key']}/stream", + ) - return data["enabled"] + self.event_stream_thread = EventStreamManager( + stream_url=stream_url, + on_event=self.handle_stream_event, + daemon=True, + ) - def get_value(self, feature_name, identity=None): - """ - Get value of given feature for an environment. + self.event_stream_thread.start() - :param feature_name: name of feature to determine value of - :param identity: (optional) application's unique identifier for the user to check feature state - :return: value of the feature state if feature exists, None otherwise - """ - if not feature_name: - return None + else: + self.environment_data_polling_manager_thread = ( + EnvironmentDataPollingManager( + main=self, + refresh_interval_seconds=self.environment_refresh_interval_seconds, + daemon=True, + ) + ) - data = self._get_flags_response(feature_name, identity) + self.environment_data_polling_manager_thread.start() - if not data: - return None - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) - return data["feature_state_value"] + def handle_stream_event(self, event: StreamEvent) -> None: + if not (environment_updated_at := self._environment_updated_at): + raise ValueError( + "Cannot handle stream events before retrieving initial environment" + ) + if event["updated_at"] > environment_updated_at: + self.update_environment() - def get_trait(self, trait_key, identity): + def get_environment_flags(self) -> Flags: """ - Get value of given trait for the identity of an environment. + Get all the default for flags for the current environment. - :param trait_key: key of trait to determine value of (must match 'ID' on flagsmith.com) - :param identity: application's unique identifier for the user to check feature state - :return: Trait value. None otherwise. + :return: Flags object holding all the flags for the current environment. """ - if not all([trait_key, identity]): - return None - - data = self._get_flags_response(identity=identity, feature_name=None) - - traits = data["traits"] - for trait in traits: - if trait.get("trait_key") == trait_key: - return trait.get("trait_value") + if ( + self.offline_mode or self.enable_local_evaluation + ) and self._evaluation_context: + return self._get_environment_flags_from_document() + return self._get_environment_flags_from_api() + + def get_identity_flags( + self, + identifier: str, + traits: typing.Optional[TraitMapping] = None, + *, + transient: bool = False, + ) -> Flags: + """ + Get all the flags for the current environment for a given identity. Will also + upsert all traits to the Flagsmith API for future evaluations. Providing a + trait with a value of None will remove the trait from the identity if it exists. + + :param identifier: a unique identifier for the identity in the current + environment, e.g. email address, username, uuid + :param traits: a dictionary of traits to add / update on the identity in + Flagsmith, e.g. `{"num_orders": 10}`. Envelope traits you don't want persisted + in a dictionary with `"transient"` and `"value"` keys, e.g. + `{"num_orders": 10, "color": {"value": "pink", "transient": True}}`. + :param transient: if `True`, the identity won't get persisted + :return: Flags object holding all the flags for the given identity. + """ + traits = traits or {} + if ( + self.offline_mode or self.enable_local_evaluation + ) and self._evaluation_context: + return self._get_identity_flags_from_document(identifier, traits) + return self._get_identity_flags_from_api( + identifier, + traits, + transient=transient, + ) - def set_trait(self, trait_key, trait_value, identity): + def get_identity_segments( + self, + identifier: str, + traits: typing.Optional[typing.Mapping[str, engine.ContextValue]] = None, + ) -> typing.List[Segment]: """ - Set value of given trait for the identity of an environment. Note that this will lazily create - a new trait if the trait_key has not been seen before for this identity + Get a list of segments that the given identity is in. - :param trait_key: key of trait - :param trait_value: value of trait - :param identity: application's unique identifier for the user to check feature state + :param identifier: a unique identifier for the identity in the current + environment, e.g. email address, username, uuid + :param traits: a dictionary of traits to add / update on the identity in + Flagsmith, e.g. {"num_orders": 10} + :return: list of Segment objects that the identity is part of. """ - values = [trait_key, trait_value, identity] - if None in values or "" in values: - return None - - payload = { - "identity": {"identifier": identity}, - "trait_key": trait_key, - "trait_value": trait_value, - } + if not self._evaluation_context: + raise FlagsmithClientError( + "Local evaluation required to obtain identity segments." + ) - requests.post( - self.traits_endpoint, - json=payload, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, + context = map_context_and_identity_data_to_context( + context=self._evaluation_context, + identifier=identifier, + traits=traits, ) - def _get_flags_response(self, feature_name=None, identity=None): - """ - Private helper method to hit the flags endpoint + evaluation_result = engine.get_evaluation_result( + context=context, + ) - :param feature_name: name of feature to determine value of (must match 'ID' on flagsmith.com) - :param identity: (optional) application's unique identifier for the user to check feature state - :return: data returned by API if successful, None if not. - """ - params = {"feature": feature_name} if feature_name else {} + return map_segment_results_to_identity_segments(evaluation_result["segments"]) + def update_environment(self) -> None: try: - if identity: - params["identifier"] = identity - response = requests.get( - self.identities_endpoint, - params=params, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, + environment_data = self._get_json_response( + self.environment_url, method="GET" + ) + except FlagsmithAPIError: + logger.exception("Error retrieving environment document from API") + else: + try: + self._evaluation_context = map_environment_document_to_context( + environment_data, ) - else: - response = requests.get( - self.flags_endpoint, - params=params, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, + self._environment_updated_at = ( + map_environment_document_to_environment_updated_at( + environment_data, + ) ) + except (KeyError, TypeError, ValueError): + logger.exception("Error parsing environment document") + + def _get_headers( + self, + environment_key: str, + application_metadata: typing.Optional[ApplicationMetadata], + custom_headers: typing.Optional[typing.Dict[str, typing.Any]], + ) -> typing.Dict[str, str]: + headers = { + "X-Environment-Key": environment_key, + "User-Agent": DEFAULT_USER_AGENT, + } + if application_metadata: + if name := application_metadata.get("name"): + headers["Flagsmith-Application-Name"] = name + if version := application_metadata.get("version"): + headers["Flagsmith-Application-Version"] = version + headers.update(custom_headers or {}) + return headers - if response.status_code == 200: - data = response.json() - if data: - return data - else: - logger.error("API didn't return any data") - return None - else: - return None + def _get_environment_flags_from_document(self) -> Flags: + if self._evaluation_context is None: + raise TypeError("No environment present") - except Exception as e: - logger.error( - "Got error getting response from API. Error message was %s" % e - ) - return None + # Omit segments from evaluation context for environment flags + # as they are only relevant for identity-specific evaluations + context_without_segments = self._evaluation_context.copy() + context_without_segments.pop("segments", None) - def _generate_header_content(self, headers=None): - """ - Generates required header content for accessing API + evaluation_result = engine.get_evaluation_result( + context=context_without_segments, + ) - :param headers: (optional) dictionary of other required header values - :return: dictionary with required environment header appended to it - """ - headers = headers or {} + return Flags.from_evaluation_result( + evaluation_result=evaluation_result, + analytics_processor=self._analytics_processor, + default_flag_handler=self.default_flag_handler, + ) - headers["X-Environment-Key"] = self.environment_id - return headers + def _get_identity_flags_from_document( + self, + identifier: str, + traits: TraitMapping, + ) -> Flags: + if self._evaluation_context is None: + raise TypeError("No environment present") + + context = map_context_and_identity_data_to_context( + context=self._evaluation_context, + identifier=identifier, + traits=traits, + ) + evaluation_result = engine.get_evaluation_result( + context=context, + ) + + return Flags.from_evaluation_result( + evaluation_result=evaluation_result, + analytics_processor=self._analytics_processor, + default_flag_handler=self.default_flag_handler, + ) + + def _get_environment_flags_from_api(self) -> Flags: + try: + json_response: typing.List[typing.Mapping[str, JsonType]] = ( + self._get_json_response(url=self.environment_flags_url, method="GET") + ) + return Flags.from_api_flags( + api_flags=json_response, + analytics_processor=self._analytics_processor, + default_flag_handler=self.default_flag_handler, + ) + except FlagsmithAPIError: + if self.offline_handler: + return self._get_environment_flags_from_document() + elif self.default_flag_handler: + return Flags(default_flag_handler=self.default_flag_handler) + raise + + def _get_identity_flags_from_api( + self, + identifier: str, + traits: TraitMapping, + *, + transient: bool = False, + ) -> Flags: + request_body = generate_identity_data( + identifier, + traits, + transient=transient, + ) + try: + json_response: typing.Dict[str, typing.List[typing.Dict[str, JsonType]]] = ( + self._get_json_response( + url=self.identities_url, + method="POST", + body=request_body, + ) + ) + return Flags.from_api_flags( + api_flags=json_response["flags"], + analytics_processor=self._analytics_processor, + default_flag_handler=self.default_flag_handler, + ) + except FlagsmithAPIError: + if self.offline_handler: + return self._get_identity_flags_from_document(identifier, traits) + elif self.default_flag_handler: + return Flags(default_flag_handler=self.default_flag_handler) + raise + + def _get_json_response( + self, + url: str, + method: str, + body: typing.Optional[JsonType] = None, + ) -> typing.Any: + try: + request_method = getattr(self.session, method.lower()) + response = request_method( + url, json=body, timeout=self.request_timeout_seconds + ) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + raise FlagsmithAPIError( + "Unable to get valid response from Flagsmith API." + ) from e + + def __del__(self) -> None: + if hasattr(self, "environment_data_polling_manager_thread"): + self.environment_data_polling_manager_thread.stop() + + if hasattr(self, "event_stream_thread"): + self.event_stream_thread.stop() diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py new file mode 100644 index 0000000..18d1acf --- /dev/null +++ b/flagsmith/mappers.py @@ -0,0 +1,272 @@ +import json +import typing +import uuid +from collections import defaultdict +from datetime import datetime, timezone + +import sseclient +from flag_engine.context.types import ( + FeatureContext, + SegmentContext, + SegmentRule, + StrValueSegmentCondition, +) +from flag_engine.result.types import SegmentResult +from flag_engine.segments.types import ContextValue + +from flagsmith.api.types import ( + EnvironmentModel, + FeatureStateModel, + IdentityModel, + SegmentRuleModel, +) +from flagsmith.models import Segment +from flagsmith.types import ( + FeatureMetadata, + SDKEvaluationContext, + SegmentMetadata, + StreamEvent, + TraitConfig, +) +from flagsmith.utils.datetime import fromisoformat + +OverrideKey = typing.Tuple[ + int, + str, + bool, + typing.Any, +] +OverridesKey = typing.Tuple[OverrideKey, ...] + + +def map_segment_results_to_identity_segments( + segment_results: list[SegmentResult[SegmentMetadata]], +) -> list[Segment]: + identity_segments: list[Segment] = [] + for segment_result in segment_results: + if metadata := segment_result.get("metadata"): + if metadata.get("source") == "api" and ( + (segment_id := metadata.get("id")) is not None + ): + identity_segments.append( + Segment( + id=segment_id, + name=segment_result["name"], + ) + ) + return identity_segments + + +def map_sse_event_to_stream_event(event: sseclient.Event) -> StreamEvent: + event_data = json.loads(event.data) + return { + "updated_at": datetime.fromtimestamp( + event_data["updated_at"], + tz=timezone.utc, + ) + } + + +def map_environment_document_to_environment_updated_at( + environment_document: dict[str, typing.Any], +) -> datetime: + if (updated_at := fromisoformat(environment_document["updated_at"])).tzinfo is None: + return updated_at.replace(tzinfo=timezone.utc) + return updated_at.astimezone(tz=timezone.utc) + + +def map_context_and_identity_data_to_context( + context: SDKEvaluationContext, + identifier: str, + traits: typing.Optional[ + typing.Mapping[ + str, + typing.Union[ + ContextValue, + TraitConfig, + ], + ] + ], +) -> SDKEvaluationContext: + return { + **context, + "identity": { + "identifier": identifier, + "traits": { + trait_key: ( + trait_value_or_config["value"] + if isinstance(trait_value_or_config, dict) + else trait_value_or_config + ) + for trait_key, trait_value_or_config in (traits or {}).items() + }, + }, + } + + +def map_environment_document_to_context( + environment_document: EnvironmentModel, +) -> SDKEvaluationContext: + return { + "environment": { + "key": environment_document["api_key"], + "name": environment_document["name"], + }, + "features": { + feature["name"]: feature + for feature in _map_environment_document_feature_states_to_feature_contexts( + environment_document["feature_states"] + ) + }, + "segments": { + **{ + (segment_key := str(segment_id := segment["id"])): { + "key": segment_key, + "name": segment["name"], + "rules": _map_environment_document_rules_to_context_rules( + segment["rules"] + ), + "overrides": list( + _map_environment_document_feature_states_to_feature_contexts( + segment.get("feature_states") or [] + ) + ), + "metadata": SegmentMetadata( + id=segment_id, + source="api", + ), + } + for segment in environment_document["project"]["segments"] + }, + **_map_identity_overrides_to_segments( + environment_document.get("identity_overrides") or [] + ), + }, + } + + +def _map_identity_overrides_to_segments( + identity_overrides: list[IdentityModel], +) -> dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]: + features_to_identifiers: typing.Dict[ + OverridesKey, + typing.List[str], + ] = defaultdict(list) + for identity_override in identity_overrides: + identity_features = identity_override["identity_features"] + if not identity_features: + continue + overrides_key = tuple( + ( + feature_state["feature"]["id"], + feature_state["feature"]["name"], + feature_state["enabled"], + feature_state["feature_state_value"], + ) + for feature_state in sorted( + identity_features, + key=lambda feature_state: feature_state["feature"]["name"], + ) + ) + features_to_identifiers[overrides_key].append(identity_override["identifier"]) + segment_contexts: typing.Dict[ + str, + SegmentContext[ + SegmentMetadata, + FeatureMetadata, + ], + ] = {} + for overrides_key, identifiers in features_to_identifiers.items(): + # Create a segment context for each unique set of overrides + # Generate a unique key to avoid collisions + segment_key = str(hash(overrides_key)) + segment_contexts[segment_key] = SegmentContext( + key="", # Identity override segments never use % Split operator + name="identity_overrides", + rules=[ + { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.identifier", + "operator": "IN", + "value": identifiers, + } + ], + } + ], + overrides=[ + { + "key": "", # Identity overrides never carry multivariate options + "name": feature_name, + "enabled": feature_enabled, + "value": feature_value, + "priority": float("-inf"), # Highest possible priority + "metadata": {"id": feature_id}, + } + for feature_id, feature_name, feature_enabled, feature_value in overrides_key + ], + metadata=SegmentMetadata(source="identity_overrides"), + ) + return segment_contexts + + +def _map_environment_document_rules_to_context_rules( + rules: list[SegmentRuleModel], +) -> list[SegmentRule]: + return [ + dict( + type=rule["type"], + conditions=[ + StrValueSegmentCondition( + property=condition.get("property_") or "", + operator=condition["operator"], + value=condition["value"], + ) + for condition in rule.get("conditions", []) + ], + rules=_map_environment_document_rules_to_context_rules( + rule.get("rules", []) + ), + ) + for rule in rules + ] + + +def _map_environment_document_feature_states_to_feature_contexts( + feature_states: list[FeatureStateModel], +) -> typing.Iterable[FeatureContext[FeatureMetadata]]: + for feature_state in feature_states: + metadata: FeatureMetadata = {"id": feature_state["feature"]["id"]} + feature_context = FeatureContext[FeatureMetadata]( + key=str( + feature_state.get("django_id") or feature_state["featurestate_uuid"] + ), + name=feature_state["feature"]["name"], + enabled=feature_state["enabled"], + value=feature_state["feature_state_value"], + metadata=metadata, + ) + if multivariate_feature_state_values := feature_state.get( + "multivariate_feature_state_values" + ): + feature_context["variants"] = [ + { + "value": multivariate_feature_state_value[ + "multivariate_feature_option" + ]["value"], + "weight": multivariate_feature_state_value["percentage_allocation"], + "priority": ( + multivariate_feature_state_value.get("id") + or uuid.UUID( + multivariate_feature_state_value["mv_fs_value_uuid"] + ).int + ), + } + for multivariate_feature_state_value in multivariate_feature_state_values + ] + + if feature_segment := feature_state.get("feature_segment"): + feature_context["priority"] = feature_segment["priority"] + + yield feature_context diff --git a/flagsmith/models.py b/flagsmith/models.py new file mode 100644 index 0000000..72beb25 --- /dev/null +++ b/flagsmith/models.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import typing +from dataclasses import dataclass, field + +from flagsmith.analytics import AnalyticsProcessor +from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError +from flagsmith.types import SDKEvaluationResult, SDKFlagResult + + +@dataclass +class BaseFlag: + enabled: bool + value: typing.Union[str, int, float, bool, None] + + +@dataclass +class DefaultFlag(BaseFlag): + is_default: bool = field(default=True) + + +@dataclass +class Flag(BaseFlag): + feature_id: int + feature_name: str + is_default: bool = field(default=False) + + @classmethod + def from_evaluation_result( + cls, + flag_result: SDKFlagResult, + ) -> Flag: + if metadata := flag_result.get("metadata"): + return Flag( + enabled=flag_result["enabled"], + value=flag_result["value"], + feature_name=flag_result["name"], + feature_id=metadata["id"], + ) + raise ValueError( + "FlagResult metadata is missing. Cannot create Flag instance. " + "This means a bug in the SDK, please report it." + ) + + @classmethod + def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: + return Flag( + enabled=flag_data["enabled"], + value=flag_data["feature_state_value"], + feature_name=flag_data["feature"]["name"], + feature_id=flag_data["feature"]["id"], + ) + + +@dataclass +class Flags: + flags: typing.Dict[str, Flag] = field(default_factory=dict) + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]] = None + _analytics_processor: typing.Optional[AnalyticsProcessor] = None + + @classmethod + def from_evaluation_result( + cls, + evaluation_result: SDKEvaluationResult, + analytics_processor: typing.Optional[AnalyticsProcessor], + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], + ) -> Flags: + return cls( + flags={ + flag_name: flag + for flag_name, flag_result in evaluation_result["flags"].items() + if (flag := Flag.from_evaluation_result(flag_result)) + }, + default_flag_handler=default_flag_handler, + _analytics_processor=analytics_processor, + ) + + @classmethod + def from_api_flags( + cls, + api_flags: typing.Sequence[typing.Mapping[str, typing.Any]], + analytics_processor: typing.Optional[AnalyticsProcessor], + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], + ) -> Flags: + flags = { + flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) + for flag_data in api_flags + } + + return cls( + flags=flags, + default_flag_handler=default_flag_handler, + _analytics_processor=analytics_processor, + ) + + def all_flags(self) -> typing.List[Flag]: + """ + Get a list of all Flag objects. + + :return: list of Flag objects. + """ + return list(self.flags.values()) + + def is_feature_enabled(self, feature_name: str) -> bool: + """ + Check whether a given feature is enabled. + + :param feature_name: the name of the feature to check if enabled. + :return: Boolean representing the enabled state of a given feature. + :raises FlagsmithClientError: if feature doesn't exist + """ + return self.get_flag(feature_name).enabled + + def get_feature_value(self, feature_name: str) -> typing.Any: + """ + Get the value of a particular feature. + + :param feature_name: the name of the feature to retrieve the value of. + :return: the value of the given feature. + :raises FlagsmithClientError: if feature doesn't exist + """ + return self.get_flag(feature_name).value + + def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: + """ + Get a specific flag given the feature name. + + :param feature_name: the name of the feature to retrieve the flag for. + :return: DefaultFlag | Flag object. + :raises FlagsmithClientError: if feature doesn't exist + """ + try: + flag = self.flags[feature_name] + except KeyError: + if self.default_flag_handler: + return self.default_flag_handler(feature_name) + raise FlagsmithFeatureDoesNotExistError( + "Feature does not exist: %s" % feature_name + ) + + if self._analytics_processor and hasattr(flag, "feature_name"): + self._analytics_processor.track_feature(flag.feature_name) + + return flag + + +@dataclass +class Segment: + id: int + name: str diff --git a/flagsmith/offline_handlers.py b/flagsmith/offline_handlers.py new file mode 100644 index 0000000..3920120 --- /dev/null +++ b/flagsmith/offline_handlers.py @@ -0,0 +1,36 @@ +import json +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Protocol + +from flagsmith.api.types import EnvironmentModel +from flagsmith.mappers import map_environment_document_to_context + + +class OfflineHandler(Protocol): + def get_environment(self) -> EnvironmentModel: ... + + +class BaseOfflineHandler(ABC): + @abstractmethod + def get_environment(self) -> EnvironmentModel: + raise NotImplementedError() + + +class LocalFileHandler: + """ + Handler to load evaluation context from a local JSON file containing the environment document. + The JSON file should contain the environment document as returned by the Flagsmith API. + + API documentation: + https://api.flagsmith.com/api/v1/docs/#/api/api_v1_environment-document_list + """ + + def __init__(self, file_path: str) -> None: + environment_document = json.loads(Path(file_path).read_text()) + # Make sure the document can be used for evaluation + map_environment_document_to_context(environment_document) + self.environment_document: EnvironmentModel = environment_document + + def get_environment(self) -> EnvironmentModel: + return self.environment_document diff --git a/flagsmith/polling_manager.py b/flagsmith/polling_manager.py new file mode 100644 index 0000000..b152e68 --- /dev/null +++ b/flagsmith/polling_manager.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging +import threading +import time +import typing + +if typing.TYPE_CHECKING: + from flagsmith import Flagsmith + +logger = logging.getLogger(__name__) + + +class EnvironmentDataPollingManager(threading.Thread): + def __init__( + self, + *args: typing.Any, + main: Flagsmith, + refresh_interval_seconds: typing.Union[int, float] = 10, + **kwargs: typing.Any, + ): + super(EnvironmentDataPollingManager, self).__init__(*args, **kwargs) + self._stop_event = threading.Event() + self.main = main + self.refresh_interval_seconds = refresh_interval_seconds + + def run(self) -> None: + while not self._stop_event.is_set(): + self.main.update_environment() + time.sleep(self.refresh_interval_seconds) + + def stop(self) -> None: + self._stop_event.set() + + def __del__(self) -> None: + self._stop_event.set() diff --git a/flagsmith/py.typed b/flagsmith/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py new file mode 100644 index 0000000..c4f2c6c --- /dev/null +++ b/flagsmith/streaming_manager.py @@ -0,0 +1,50 @@ +import logging +import threading +import typing +from typing import Callable, Optional + +import requests +import sseclient + +from flagsmith.mappers import map_sse_event_to_stream_event +from flagsmith.types import StreamEvent + +logger = logging.getLogger(__name__) + + +class EventStreamManager(threading.Thread): + def __init__( + self, + *args: typing.Any, + stream_url: str, + on_event: Callable[[StreamEvent], None], + request_timeout_seconds: Optional[int] = None, + **kwargs: typing.Any, + ) -> None: + super().__init__(*args, **kwargs) + self._stop_event = threading.Event() + self.stream_url = stream_url + self.on_event = on_event + self.request_timeout_seconds = request_timeout_seconds + + def run(self) -> None: + while not self._stop_event.is_set(): + try: + with requests.get( + self.stream_url, + stream=True, + headers={"Accept": "application/json, text/event-stream"}, + timeout=self.request_timeout_seconds, + ) as response: + sse_client = sseclient.SSEClient(chunk for chunk in response) + for event in sse_client.events(): + self.on_event(map_sse_event_to_stream_event(event)) + + except (requests.RequestException, ValueError, TypeError): + logger.exception("Error opening or reading from the event stream") + + def stop(self) -> None: + self._stop_event.set() + + def __del__(self) -> None: + self._stop_event.set() diff --git a/flagsmith/types.py b/flagsmith/types.py new file mode 100644 index 0000000..8a50741 --- /dev/null +++ b/flagsmith/types.py @@ -0,0 +1,54 @@ +import typing +from datetime import datetime + +from flag_engine.context.types import EvaluationContext +from flag_engine.engine import ContextValue +from flag_engine.result.types import EvaluationResult, FlagResult +from typing_extensions import NotRequired, TypeAlias + +_JsonScalarType: TypeAlias = typing.Union[ + int, + str, + float, + bool, + None, +] +JsonType: TypeAlias = typing.Union[ + _JsonScalarType, + typing.Dict[str, "JsonType"], + typing.List["JsonType"], +] + + +class StreamEvent(typing.TypedDict): + updated_at: datetime + + +class TraitConfig(typing.TypedDict): + value: ContextValue + transient: bool + + +TraitMapping: TypeAlias = typing.Mapping[str, typing.Union[ContextValue, TraitConfig]] + + +class ApplicationMetadata(typing.TypedDict): + name: NotRequired[str] + version: NotRequired[str] + + +class SegmentMetadata(typing.TypedDict): + id: NotRequired[int] + """The ID of the segment used in Flagsmith API.""" + source: NotRequired[typing.Literal["api", "identity_overrides"]] + """The source of the segment, e.g. 'api', 'identity_overrides'.""" + + +class FeatureMetadata(typing.TypedDict): + id: int + """The ID of the feature used in Flagsmith API.""" + + +SDKEvaluationContext = EvaluationContext[SegmentMetadata, FeatureMetadata] +SDKEvaluationResult = EvaluationResult[SegmentMetadata, FeatureMetadata] +SDKFlagResult = FlagResult[FeatureMetadata] diff --git a/flagsmith/utils/__init__.py b/flagsmith/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flagsmith/utils/datetime.py b/flagsmith/utils/datetime.py new file mode 100644 index 0000000..4df38fb --- /dev/null +++ b/flagsmith/utils/datetime.py @@ -0,0 +1,10 @@ +import sys + +if sys.version_info >= (3, 11): + from datetime import datetime + + fromisoformat = datetime.fromisoformat +else: + import iso8601 + + fromisoformat = iso8601.parse_date diff --git a/flagsmith/utils/identities.py b/flagsmith/utils/identities.py new file mode 100644 index 0000000..a1a419e --- /dev/null +++ b/flagsmith/utils/identities.py @@ -0,0 +1,26 @@ +import typing + +from flagsmith.types import JsonType, TraitMapping + + +def generate_identity_data( + identifier: str, + traits: TraitMapping, + *, + transient: bool, +) -> JsonType: + identity_data: typing.Dict[str, JsonType] = {"identifier": identifier} + traits_data: typing.List[JsonType] = [] + for trait_key, trait_value in traits.items(): + trait_data: typing.Dict[str, JsonType] = {"trait_key": trait_key} + if isinstance(trait_value, dict): + trait_data["trait_value"] = trait_value["value"] + if trait_value.get("transient"): + trait_data["transient"] = True + else: + trait_data["trait_value"] = trait_value + traits_data.append(trait_data) + identity_data["traits"] = traits_data + if transient: + identity_data["transient"] = True + return identity_data diff --git a/flagsmith/version.py b/flagsmith/version.py new file mode 100644 index 0000000..aa2c58c --- /dev/null +++ b/flagsmith/version.py @@ -0,0 +1,3 @@ +from importlib.metadata import version + +__version__ = version("flagsmith") diff --git a/flagsmith/webhooks.py b/flagsmith/webhooks.py new file mode 100644 index 0000000..ab31ca2 --- /dev/null +++ b/flagsmith/webhooks.py @@ -0,0 +1,41 @@ +import hashlib +import hmac +from typing import Union + + +def generate_signature( + request_body: Union[str, bytes], + shared_secret: str, +) -> str: + """Generates a signature for a webhook request body using HMAC-SHA256. + + :param request_body: The raw request body, as string or bytes. + :param shared_secret: The shared secret configured for this specific webhook. + :return: The hex-encoded signature. + """ + if isinstance(request_body, str): + request_body = request_body.encode() + + shared_secret_bytes = shared_secret.encode() + + return hmac.new( + key=shared_secret_bytes, + msg=request_body, + digestmod=hashlib.sha256, + ).hexdigest() + + +def verify_signature( + request_body: Union[str, bytes], + received_signature: str, + shared_secret: str, +) -> bool: + """Verifies a webhook's signature to determine if the request was sent by Flagsmith. + + :param request_body: The raw request body, as string or bytes. + :param received_signature: The signature as received in the X-Flagsmith-Signature request header. + :param shared_secret: The shared secret configured for this specific webhook. + :return: True if the signature is valid, False otherwise. + """ + expected_signature = generate_signature(request_body, shared_secret) + return hmac.compare_digest(expected_signature, received_signature) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..6be6213 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,980 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2025.8.3" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.19.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, + {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, +] + +[[package]] +name = "filelock" +version = "3.20.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, +] + +[[package]] +name = "flagsmith-flag-engine" +version = "10.0.3" +description = "Flag engine for the Flagsmith API." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "flagsmith_flag_engine-10.0.3-py3-none-any.whl", hash = "sha256:aed9009377fc1a6322483277f971f06d542668a69d93cbe4a3efd4baae78dfc1"}, + {file = "flagsmith_flag_engine-10.0.3.tar.gz", hash = "sha256:0aa449bb87bee54fc67b5c7ca25eca78246a7bbb5a6cc229260c3f262d58ac54"}, +] + +[package.dependencies] +jsonpath-rfc9535 = ">=0.1.5,<1" +semver = ">=3.0.4,<4" +typing-extensions = ">=4.14.1,<5" + +[[package]] +name = "identify" +version = "2.6.13" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b"}, + {file = "identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main", "dev"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "iregexp-check" +version = "0.1.4" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "iregexp_check-0.1.4-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:385a90d450706b9f934b5c82137247e24423c990d250da55630a792ccb7e2974"}, + {file = "iregexp_check-0.1.4-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:003434c2d7e13ea91e2ff1d5038f87641e9dc44513a0544e3c29e91dfb21b871"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9cbb6fe0aaae0c7b9b8d4ba05a6d8283cf747dbd06b8e0442f05e87c9d5e1c"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef58d44e4ae9aaca89be2898e416e6e168aff62cd5b1820d531fa855ee8e2fb1"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a473fb428b55031f64db1e52447d5bffd6bba2f3b760052592a44951cbddd8ab"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ce621946fc42e0d9f9475bf1360d91e281f84c199cbac2de24973e55bcdc92"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c670722da7283ed15d1401eca684628248491bb612e54a41bc60f86d32b67a5"}, + {file = "iregexp_check-0.1.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8e8bb2dc1f08110dde37ae52a42f2487365178f43625995579d6cca4ec9f683"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7beffdc3179334e18975a64399915922842880b8960dc4b04903f9b1ffdad35a"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:01b374e01719d9e2a1ad141aed5e5d34acf71e156db269b578a46570d32708af"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5a7b1340c34cc8c93b80716b75f7faaec3a8662631b1c33a249d68e78d8fdab2"}, + {file = "iregexp_check-0.1.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2dc5a74d3190e0ecd7e30a5394ed6086962ebe644f21d440954ec2d11de691f0"}, + {file = "iregexp_check-0.1.4-cp38-abi3-win32.whl", hash = "sha256:b24ef4264546a899e1e3407d111024d02af42f7b8575250dc4d9fc79011e2a5c"}, + {file = "iregexp_check-0.1.4-cp38-abi3-win_amd64.whl", hash = "sha256:50837bbe9b09abdb7b387d9c7dc2eda470a77e8b29ac315a0e1409b147db14bd"}, + {file = "iregexp_check-0.1.4.tar.gz", hash = "sha256:a98e77dd2d9fc91db04f8d9f295f3d69e402813bac5413f22e5866958a902bc1"}, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, + {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, +] + +[[package]] +name = "jsonpath-rfc9535" +version = "0.1.6" +description = "RFC 9535 - JSONPath: Query Expressions for JSON in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonpath_rfc9535-0.1.6-py3-none-any.whl", hash = "sha256:dc569660a3a44c8d1d111b6fe86725680914303bedfc97fde3c15f56d89553cb"}, + {file = "jsonpath_rfc9535-0.1.6.tar.gz", hash = "sha256:0d74848f6a7a7335489648bc3c02e61ce52a527424b252e55edc12ec50305c02"}, +] + +[package.dependencies] +iregexp-check = ">=0.1.4" +regex = "*" + +[[package]] +name = "mypy" +version = "1.17.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, + {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, + {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, + {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, + {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, + {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, + {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, + {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, + {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, + {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, + {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, + {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, + {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, + {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, + {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "4.3.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, + {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pyfakefs" +version = "5.9.2" +description = "Implements a fake file system that mocks the Python file system modules." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyfakefs-5.9.2-py3-none-any.whl", hash = "sha256:8fcea36051d5b8827d49f5e2d242e91a51c5af6b78eeffa8e7adb899a6763e99"}, + {file = "pyfakefs-5.9.2.tar.gz", hash = "sha256:66c5c6ccd4097b484f8782f9a5078fee0533d465e0d9caf594c9157d54382553"}, +] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "regex" +version = "2025.7.34" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d856164d25e2b3b07b779bfed813eb4b6b6ce73c2fd818d46f47c1eb5cd79bd6"}, + {file = "regex-2025.7.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d15a9da5fad793e35fb7be74eec450d968e05d2e294f3e0e77ab03fa7234a83"}, + {file = "regex-2025.7.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:95b4639c77d414efa93c8de14ce3f7965a94d007e068a94f9d4997bb9bd9c81f"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7de1ceed5a5f84f342ba4a9f4ae589524adf9744b2ee61b5da884b5b659834"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02e5860a250cd350c4933cf376c3bc9cb28948e2c96a8bc042aee7b985cfa26f"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a5966220b9a1a88691282b7e4350e9599cf65780ca60d914a798cb791aa1177"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48fb045bbd4aab2418dc1ba2088a5e32de4bfe64e1457b948bb328a8dc2f1c2e"}, + {file = "regex-2025.7.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20ff8433fa45e131f7316594efe24d4679c5449c0ca69d91c2f9d21846fdf064"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c436fd1e95c04c19039668cfb548450a37c13f051e8659f40aed426e36b3765f"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b85241d3cfb9f8a13cefdfbd58a2843f208f2ed2c88181bf84e22e0c7fc066d"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:075641c94126b064c65ab86e7e71fc3d63e7ff1bea1fb794f0773c97cdad3a03"}, + {file = "regex-2025.7.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70645cad3407d103d1dbcb4841839d2946f7d36cf38acbd40120fee1682151e5"}, + {file = "regex-2025.7.34-cp310-cp310-win32.whl", hash = "sha256:3b836eb4a95526b263c2a3359308600bd95ce7848ebd3c29af0c37c4f9627cd3"}, + {file = "regex-2025.7.34-cp310-cp310-win_amd64.whl", hash = "sha256:cbfaa401d77334613cf434f723c7e8ba585df162be76474bccc53ae4e5520b3a"}, + {file = "regex-2025.7.34-cp310-cp310-win_arm64.whl", hash = "sha256:bca11d3c38a47c621769433c47f364b44e8043e0de8e482c5968b20ab90a3986"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a"}, + {file = "regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33"}, + {file = "regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd"}, + {file = "regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a"}, + {file = "regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1"}, + {file = "regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a"}, + {file = "regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f"}, + {file = "regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0"}, + {file = "regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da"}, + {file = "regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282"}, + {file = "regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588"}, + {file = "regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62"}, + {file = "regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd"}, + {file = "regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415"}, + {file = "regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a"}, + {file = "regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0"}, + {file = "regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1"}, + {file = "regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997"}, + {file = "regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435"}, + {file = "regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751"}, + {file = "regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47"}, + {file = "regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e"}, + {file = "regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb"}, + {file = "regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae"}, + {file = "regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fd5edc3f453de727af267c7909d083e19f6426fc9dd149e332b6034f2a5611e6"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa1cdfb8db96ef20137de5587954c812821966c3e8b48ffc871e22d7ec0a4938"}, + {file = "regex-2025.7.34-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:89c9504fc96268e8e74b0283e548f53a80c421182a2007e3365805b74ceef936"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be70d75fa05a904ee0dc43b650844e067d14c849df7e82ad673541cd465b5f"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57d25b6732ea93eeb1d090e8399b6235ca84a651b52d52d272ed37d3d2efa0f1"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:baf2fe122a3db1c0b9f161aa44463d8f7e33eeeda47bb0309923deb743a18276"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a764a83128af9c1a54be81485b34dca488cbcacefe1e1d543ef11fbace191e1"}, + {file = "regex-2025.7.34-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7f663ccc4093877f55b51477522abd7299a14c5bb7626c5238599db6a0cb95d"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4913f52fbc7a744aaebf53acd8d3dc1b519e46ba481d4d7596de3c862e011ada"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:efac4db9e044d47fd3b6b0d40b6708f4dfa2d8131a5ac1d604064147c0f552fd"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7373afae7cfb716e3b8e15d0184510d518f9d21471f2d62918dbece85f2c588f"}, + {file = "regex-2025.7.34-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9960d162f3fecf6af252534a1ae337e9c2e20d74469fed782903b24e2cc9d3d7"}, + {file = "regex-2025.7.34-cp39-cp39-win32.whl", hash = "sha256:95d538b10eb4621350a54bf14600cc80b514211d91a019dc74b8e23d2159ace5"}, + {file = "regex-2025.7.34-cp39-cp39-win_amd64.whl", hash = "sha256:f7f3071b5faa605b0ea51ec4bb3ea7257277446b053f4fd3ad02b1dcb4e64353"}, + {file = "regex-2025.7.34-cp39-cp39-win_arm64.whl", hash = "sha256:716a47515ba1d03f8e8a61c5013041c8c90f2e21f055203498105d7571b44531"}, + {file = "regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a"}, +] + +[[package]] +name = "requests" +version = "2.32.4" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-futures" +version = "1.0.2" +description = "Asynchronous Python HTTP for Humans." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "requests_futures-1.0.2-py2.py3-none-any.whl", hash = "sha256:a3534af7c2bf670cd7aa730716e9e7d4386497554f87792be7514063b8912897"}, + {file = "requests_futures-1.0.2.tar.gz", hash = "sha256:6b7eb57940336e800faebc3dab506360edec9478f7b22dc570858ad3aa7458da"}, +] + +[package.dependencies] +requests = ">=1.2.0" + +[package.extras] +dev = ["Werkzeug (>=3.0.6)", "black (>=24.3.0,<25.0.0)", "build (>=0.7.0)", "docutils (<=0.20.1)", "greenlet (<=2.0.2) ; python_version < \"3.12\"", "greenlet (>=3.0.0) ; python_version >= \"3.12.0rc0\"", "isort (>=5.11.4)", "pyflakes (>=2.2.0)", "pytest (>=6.2.5)", "pytest-cov (>=3.0.0)", "pytest-httpbin (>=2.0.0)", "readme-renderer[rst] (>=26.0)", "twine (>=3.4.2)"] + +[[package]] +name = "responses" +version = "0.24.1" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"}, + {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] + +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, +] + +[[package]] +name = "sseclient-py" +version = "1.8.0" +description = "SSE client for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "sseclient-py-1.8.0.tar.gz", hash = "sha256:c547c5c1a7633230a38dc599a21a2dc638f9b5c297286b48b46b935c71fac3e8"}, + {file = "sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83"}, +] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "urllib3" +version = "2.6.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, + {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "virtualenv" +version = "20.36.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, + {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = [ + {version = ">=3.16.1,<4", markers = "python_version < \"3.10\""}, + {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}, +] +platformdirs = ">=3.9.1,<5" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9,<4" +content-hash = "1a65acfb68f8c7f4226460c21adbcbb27a105635cb8287f6bbfc5aa9c900c5dd" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c059862 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[tool.poetry] +name = "flagsmith" +version = "5.1.1" +description = "Flagsmith Python SDK" +authors = ["Flagsmith "] +license = "BSD3" +readme = "README.md" +keywords = ["feature", "flag", "flagsmith", "remote", "config"] +documentation = "https://docs.flagsmith.com" +packages = [{ include = "flagsmith" }] + +[tool.poetry.dependencies] +flagsmith-flag-engine = "^10.0.3" +iso8601 = { version = "^2.1.0", python = "<3.11" } +python = ">=3.9,<4" +requests = "^2.32.3" +requests-futures = "^1.0.1" +sseclient-py = "^1.8.0" +typing-extensions = "^4.15.0" + +[tool.poetry.group.dev] +optional = true + +[tool.poetry.group.dev.dependencies] +mypy = { version = "^1.16.1", python = ">=3.9,<4" } +pytest = "^7.4.0" +pytest-cov = "^4.1.0" +pytest-mock = "^3.6.1" +pre-commit = { version = "^4.2.0", python = ">=3.9,<4" } +responses = "^0.24.1" +types-requests = "^2.32" +pyfakefs = "^5.9.2" + +[tool.mypy] +exclude = ["example/*"] + +[tool.black] +target-version = ["py39"] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..41d8efe --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,62 @@ +{ + "bootstrap-sha": "fd2094131e5e4972a17be5a8efa4cb379cc821ee", + "packages": { + ".": { + "release-type": "python", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false, + "include-component-in-tag": false + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "ci", + "hidden": false, + "section": "CI" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "deps", + "hidden": false, + "section": "Dependency Updates" + }, + { + "type": "perf", + "hidden": false, + "section": "Performance Improvements" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "test", + "hidden": false, + "section": "Tests" + }, + { + "type": "chore", + "hidden": false, + "section": "Other" + } + ] +} diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 518bcdc..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt -pytest==5.1.2 -black -pre-commit diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index abffd05..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests>=2.19.1 -requests-futures==1.0.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 0bc5bd0..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = Readme.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 2319c6c..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -from setuptools import setup - -with open("Readme.md", "r") as readme: - long_description = readme.read() - -setup( - name="flagsmith", - version="2.0.1", - packages=["flagsmith"], - description="Flagsmith Python SDK", - long_description=long_description, - long_description_content_type="text/markdown", - author="Bullet Train Ltd", - author_email="supoprt@flagsmith.com", - license="BSD3", - url="https://github.com/Flagsmith/flagsmith-python-client", - keywords=["feature", "flag", "flagsmith", "remote", "config"], - install_requires=[ - "requests>=2.19.1", - ], - classifiers=[ - "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.0", - "Programming Language :: Python :: 3.1", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], -) diff --git a/tests/conftest.py b/tests/conftest.py index 7f0cc1c..ce1153c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,103 @@ +import json +import os +import random +import string +import typing +from typing import Generator + import pytest +import responses +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture +from flagsmith import Flagsmith from flagsmith.analytics import AnalyticsProcessor +from flagsmith.api.types import EnvironmentModel +from flagsmith.mappers import map_environment_document_to_context +from flagsmith.types import SDKEvaluationContext +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") -@pytest.fixture -def analytics_processor(): + +@pytest.fixture() +def analytics_processor() -> AnalyticsProcessor: return AnalyticsProcessor( environment_key="test_key", base_api_url="http://test_url" ) + + +@pytest.fixture(scope="session") +def api_key() -> str: + return "".join(random.sample(string.ascii_letters, 20)) + + +@pytest.fixture(scope="session") +def server_api_key() -> str: + return "ser.%s" % "".join(random.sample(string.ascii_letters, 20)) + + +@pytest.fixture() +def flagsmith(api_key: str) -> Flagsmith: + return Flagsmith(environment_key=api_key) + + +@pytest.fixture() +def environment_json(fs: FakeFilesystem) -> typing.Generator[str, None, None]: + environment_json_path = os.path.join(DATA_DIR, "environment.json") + fs.add_real_file(environment_json_path) + with open(environment_json_path, "rt") as f: + yield f.read() + + +@pytest.fixture() +def requests_session_response_ok(mocker: MockerFixture, environment_json: str) -> None: + mock_session = mocker.MagicMock() + mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) + + mock_environment_document_response = mocker.MagicMock(status_code=200) + mock_environment_document_response.json.return_value = json.loads(environment_json) + mock_session.get.return_value = mock_environment_document_response + + +@pytest.fixture() +def local_eval_flagsmith( + requests_session_response_ok: None, server_api_key: str +) -> Generator[Flagsmith, None, None]: + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + environment_refresh_interval_seconds=0.1, + ) + + yield flagsmith + + del flagsmith + + +@pytest.fixture() +def environment(environment_json: str) -> EnvironmentModel: + ret: EnvironmentModel = json.loads(environment_json) + return ret + + +@pytest.fixture() +def evaluation_context(environment: EnvironmentModel) -> SDKEvaluationContext: + return map_environment_document_to_context(environment) + + +@pytest.fixture() +def flags_json() -> typing.Generator[str, None, None]: + with open(os.path.join(DATA_DIR, "flags.json"), "rt") as f: + yield f.read() + + +@pytest.fixture() +def identities_json() -> typing.Generator[str, None, None]: + with open(os.path.join(DATA_DIR, "identities.json"), "rt") as f: + yield f.read() + + +@pytest.fixture +def mocked_responses() -> Generator["responses.RequestsMock", None, None]: + with responses.RequestsMock() as rsps: + yield rsps diff --git a/tests/data/environment.json b/tests/data/environment.json new file mode 100644 index 0000000..6dc7e51 --- /dev/null +++ b/tests/data/environment.json @@ -0,0 +1,84 @@ +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "name": "Test Environment", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [ + { + "id": 1, + "name": "Test segment", + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "operator": "EQUAL", + "property_": "foo", + "value": "bar" + } + ] + } + ] + } + ] + } + ] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "some-value", + "django_id": 1, + "featurestate_uuid": "799d42c3-e973-4d43-957a-35a2aea169c1", + "feature": { + "name": "some_feature", + "type": "STANDARD", + "id": 1 + }, + "segment_id": null, + "enabled": true, + "feature_segment": null + } + ], + "updated_at": "2023-07-14T16:12:00.000000Z", + "identity_overrides": [ + { + "identifier": "overridden-id", + "identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01", + "created_date": "2019-08-27T14:53:45.698555Z", + "updated_at": "2023-07-14T16:12:00.000000Z", + "environment_api_key": "B62qaMZNwfiqT76p38ggrQ", + "identity_features": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "some_feature", + "type": "STANDARD" + }, + "featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f", + "feature_state_value": "some-overridden-value", + "enabled": false, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] + } + ] +} diff --git a/tests/data/get-flags.json b/tests/data/flags.json similarity index 60% rename from tests/data/get-flags.json rename to tests/data/flags.json index 6ffd5c5..a2f226b 100644 --- a/tests/data/get-flags.json +++ b/tests/data/flags.json @@ -3,17 +3,18 @@ "id": 1, "feature": { "id": 1, - "name": "test", + "name": "some_feature", "created_date": "2019-08-27T14:53:45.698555Z", "initial_value": null, "description": null, "default_enabled": false, - "type": "FLAG", + "type": "STANDARD", "project": 1 }, - "feature_state_value": null, - "enabled": false, + "feature_state_value": "some-value", + "enabled": true, "environment": 1, - "identity": null + "identity": null, + "feature_segment": null } -] \ No newline at end of file +] diff --git a/tests/data/get-flag-for-specific-feature-disabled.json b/tests/data/get-flag-for-specific-feature-disabled.json deleted file mode 100644 index 38a8c58..0000000 --- a/tests/data/get-flag-for-specific-feature-disabled.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/get-flag-for-specific-feature-enabled.json b/tests/data/get-flag-for-specific-feature-enabled.json deleted file mode 100644 index f7f5205..0000000 --- a/tests/data/get-flag-for-specific-feature-enabled.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": true, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/get-identity-flags-with-trait.json b/tests/data/get-identity-flags-with-trait.json deleted file mode 100644 index 11fca91..0000000 --- a/tests/data/get-identity-flags-with-trait.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "flags": [ - { - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null - } - ], - "traits": [ - { - "trait_key": "trait_key", - "trait_value": "trait_value" - } - ] -} \ No newline at end of file diff --git a/tests/data/get-identity-flags-without-trait.json b/tests/data/get-identity-flags-without-trait.json deleted file mode 100644 index c5d7a15..0000000 --- a/tests/data/get-identity-flags-without-trait.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "flags": [ - { - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null - } - ], - "traits": [] -} \ No newline at end of file diff --git a/tests/data/get-value-for-specific-feature.json b/tests/data/get-value-for-specific-feature.json deleted file mode 100644 index fd5a871..0000000 --- a/tests/data/get-value-for-specific-feature.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": "Test value", - "enabled": false, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/identities.json b/tests/data/identities.json new file mode 100644 index 0000000..c829bfd --- /dev/null +++ b/tests/data/identities.json @@ -0,0 +1,29 @@ +{ + "traits": [ + { + "id": 1, + "trait_key": "some_trait", + "trait_value": "some_value" + } + ], + "flags": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "some_feature", + "created_date": "2019-08-27T14:53:45.698555Z", + "initial_value": null, + "description": null, + "default_enabled": false, + "type": "STANDARD", + "project": 1 + }, + "feature_state_value": "some-value", + "enabled": true, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] +} diff --git a/tests/data/not-found.json b/tests/data/not-found.json deleted file mode 100644 index 50cc092..0000000 --- a/tests/data/not-found.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "detail": "Given feature not found" -} \ No newline at end of file diff --git a/tests/test_analytics.py b/tests/test_analytics.py index d6e70ea..daaf420 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -5,39 +5,43 @@ from flagsmith.analytics import ANALYTICS_TIMER, AnalyticsProcessor -def test_analytics_processor_track_feature_updates_analytics_data(analytics_processor): +def test_analytics_processor_track_feature_updates_analytics_data( + analytics_processor: AnalyticsProcessor, +) -> None: # When - analytics_processor.track_feature(1) - assert analytics_processor.analytics_data[1] == 1 + analytics_processor.track_feature("my_feature") + assert analytics_processor.analytics_data["my_feature"] == 1 - analytics_processor.track_feature(1) - assert analytics_processor.analytics_data[1] == 2 + analytics_processor.track_feature("my_feature") + assert analytics_processor.analytics_data["my_feature"] == 2 -def test_analytics_processor_flush_clears_analytics_data(analytics_processor): - analytics_processor.track_feature(1) +def test_analytics_processor_flush_clears_analytics_data( + analytics_processor: AnalyticsProcessor, +) -> None: + analytics_processor.track_feature("my_feature") analytics_processor.flush() assert analytics_processor.analytics_data == {} def test_analytics_processor_flush_post_request_data_match_ananlytics_data( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: # Given with mock.patch("flagsmith.analytics.session") as session: # When - analytics_processor.track_feature(1) - analytics_processor.track_feature(2) + analytics_processor.track_feature("my_feature_1") + analytics_processor.track_feature("my_feature_2") analytics_processor.flush() # Then session.post.assert_called() post_call = session.mock_calls[0] - assert {"1": 1, "2": 1} == json.loads(post_call[2]["data"]) + assert {"my_feature_1": 1, "my_feature_2": 1} == json.loads(post_call[2]["data"]) def test_analytics_processor_flush_early_exit_if_analytics_data_is_empty( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: with mock.patch("flagsmith.analytics.session") as session: analytics_processor.flush() @@ -46,18 +50,19 @@ def test_analytics_processor_flush_early_exit_if_analytics_data_is_empty( def test_analytics_processor_calling_track_feature_calls_flush_when_timer_runs_out( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: # Given - with mock.patch("flagsmith.analytics.datetime") as mocked_datetime, mock.patch( - "flagsmith.analytics.session" - ) as session: + with ( + mock.patch("flagsmith.analytics.datetime") as mocked_datetime, + mock.patch("flagsmith.analytics.session") as session, + ): # Let's move the time mocked_datetime.now.return_value = datetime.now() + timedelta( seconds=ANALYTICS_TIMER + 1 ) # When - analytics_processor.track_feature(1) + analytics_processor.track_feature("my_feature") # Then session.post.assert_called() diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index b19df9d..494e854 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -1,151 +1,917 @@ import json -import logging -import os -from unittest import TestCase, mock +import time +import typing + +import pytest +import requests +import responses +from pytest_mock import MockerFixture +from responses import matchers + +from flagsmith import Flagsmith, __version__ +from flagsmith.api.types import EnvironmentModel +from flagsmith.exceptions import ( + FlagsmithAPIError, + FlagsmithFeatureDoesNotExistError, +) +from flagsmith.models import DefaultFlag, Flags +from flagsmith.offline_handlers import OfflineHandler +from flagsmith.types import SDKEvaluationContext + + +def test_flagsmith_starts_polling_manager_on_init_if_enabled( + mocker: MockerFixture, server_api_key: str, requests_session_response_ok: None +) -> None: + # Given + mock_polling_manager = mocker.MagicMock() + mocker.patch( + "flagsmith.flagsmith.EnvironmentDataPollingManager", + return_value=mock_polling_manager, + ) -from flagsmith import Flagsmith + # When + Flagsmith(environment_key=server_api_key, enable_local_evaluation=True) + + # Then + mock_polling_manager.start.assert_called_once() + + +@responses.activate() +def test_update_environment_sets_environment( + flagsmith: Flagsmith, + environment_json: str, + evaluation_context: SDKEvaluationContext, +) -> None: + # Given + responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) + assert flagsmith._evaluation_context is None + + # When + flagsmith.update_environment() + + # Then + assert flagsmith._evaluation_context is not None + assert flagsmith._evaluation_context == evaluation_context + + +@responses.activate() +def test_get_environment_flags_calls_api_when_no_local_environment( + api_key: str, flagsmith: Flagsmith, flags_json: str +) -> None: + # Given + responses.add(method="GET", url=flagsmith.environment_flags_url, body=flags_json) + + # When + all_flags = flagsmith.get_environment_flags().all_flags() + + # Then + assert len(responses.calls) == 1 + assert responses.calls[0].request.headers["X-Environment-Key"] == api_key + + # Taken from hard coded values in tests/data/flags.json + assert all_flags[0].enabled is True + assert all_flags[0].value == "some-value" + assert all_flags[0].feature_name == "some_feature" + + +@responses.activate() +def test_get_environment_flags_uses_local_environment_when_available( + flagsmith: Flagsmith, + evaluation_context: SDKEvaluationContext, +) -> None: + # Given + flagsmith._evaluation_context = evaluation_context + flagsmith.enable_local_evaluation = True + + # When + all_flags = flagsmith.get_environment_flags().all_flags() + + # Then + assert len(responses.calls) == 0 + assert len(all_flags) == 1 + assert all_flags[0].feature_name == "some_feature" + assert all_flags[0].enabled is True + assert all_flags[0].value == "some-value" + + +def test_get_environment_flags_omits_segments_from_evaluation_context( + mocker: MockerFixture, + local_eval_flagsmith: Flagsmith, + evaluation_context: SDKEvaluationContext, +) -> None: + # Given + mock_get_evaluation_result = mocker.patch( + "flagsmith.flagsmith.engine.get_evaluation_result", + autospec=True, + ) -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) + expected_evaluation_result = { + "flags": { + "some_feature": { + "name": "some_feature", + "enabled": True, + "value": "some-feature-state-value", + "metadata": {"id": 1}, + } + }, + "segments": [], + } + + mock_get_evaluation_result.return_value = expected_evaluation_result + + # When + local_eval_flagsmith.get_environment_flags() + + # Then + # Verify segments are not present in the context passed to the engine + context_without_segments = evaluation_context.copy() + context_without_segments.pop("segments", None) + mock_get_evaluation_result.assert_called_once_with(context=context_without_segments) + + +@responses.activate() +def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( + flagsmith: Flagsmith, identities_json: str +) -> None: + # Given + responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) + identifier = "identifier" + + # When + identity_flags = flagsmith.get_identity_flags(identifier=identifier).all_flags() + + # Then + body = responses.calls[0].request.body + if isinstance(body, bytes): + # Decode 'body' from bytes to string if it is in bytes format. + body = body.decode() + assert body == json.dumps({"identifier": identifier, "traits": []}) + + # Taken from hard coded values in tests/data/identities.json + assert identity_flags[0].enabled is True + assert identity_flags[0].value == "some-value" + assert identity_flags[0].feature_name == "some_feature" + + +@responses.activate() +def test_get_identity_flags_calls_api_when_no_local_environment_with_traits( + flagsmith: Flagsmith, identities_json: str +) -> None: + # Given + responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) + identifier = "identifier" + traits = {"some_trait": "some_value"} + + # When + identity_flags = flagsmith.get_identity_flags(identifier=identifier, traits=traits) + + # Then + body = responses.calls[0].request.body + if isinstance(body, bytes): + # Decode 'body' from bytes to string if it is in bytes format. + body = body.decode() + assert body == json.dumps( + { + "identifier": identifier, + "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], + } + ) -TEST_API_URL = "https://test.bullet-train.io/api" -TEST_IDENTIFIER = "test-identity" -TEST_FEATURE = "test-feature" + # Taken from hard coded values in tests/data/identities.json + assert identity_flags.all_flags()[0].enabled is True + assert identity_flags.all_flags()[0].value == "some-value" + assert identity_flags.all_flags()[0].feature_name == "some_feature" + + +@responses.activate() +def test_get_identity_flags_uses_local_environment_when_available( + flagsmith: Flagsmith, + evaluation_context: SDKEvaluationContext, + mocker: MockerFixture, +) -> None: + # Given + flagsmith._evaluation_context = evaluation_context + flagsmith.enable_local_evaluation = True + mock_engine = mocker.patch("flagsmith.flagsmith.engine") + + expected_evaluation_result = { + "flags": { + "some_feature": { + "name": "some_feature", + "enabled": True, + "value": "some-feature-state-value", + "metadata": {"id": 1}, + } + }, + "segments": [], + } + + identifier = "identifier" + traits = {"some_trait": "some_value"} + + mock_engine.get_evaluation_result.return_value = expected_evaluation_result + + # When + identity_flags = flagsmith.get_identity_flags(identifier, traits).all_flags() + + # Then + mock_engine.get_evaluation_result.assert_called_once() + call_args = mock_engine.get_evaluation_result.call_args + context = call_args[1]["context"] + assert context["identity"]["identifier"] == identifier + assert context["identity"]["traits"]["some_trait"] == "some_value" + assert "some_trait" in context["identity"]["traits"] + + assert identity_flags[0].enabled is True + assert identity_flags[0].value == "some-feature-state-value" + + +def test_get_identity_flags_includes_segments_in_evaluation_context( + mocker: MockerFixture, + local_eval_flagsmith: Flagsmith, +) -> None: + # Given + mock_get_evaluation_result = mocker.patch( + "flagsmith.flagsmith.engine.get_evaluation_result", + autospec=True, + ) + expected_evaluation_result = { + "flags": { + "some_feature": { + "name": "some_feature", + "enabled": True, + "value": "some-feature-state-value", + "metadata": {"id": 1}, + } + }, + "segments": [], + } + + identifier = "identifier" + traits = {"some_trait": "some_value"} + + mock_get_evaluation_result.return_value = expected_evaluation_result + + # When + local_eval_flagsmith.get_identity_flags(identifier, traits) + + # Then + # Verify segments are present in the context passed to the engine for identity flags + call_args = mock_get_evaluation_result.call_args + context = call_args[1]["context"] + assert "segments" in context + + +@responses.activate() +def test_get_identity_flags__transient_identity__calls_expected( + flagsmith: Flagsmith, + identities_json: str, +) -> None: + # Given + responses.add( + method="POST", + url=flagsmith.identities_url, + body=identities_json, + match=[ + matchers.json_params_matcher( + { + "identifier": "identifier", + "traits": [ + {"trait_key": "some_trait", "trait_value": "some_value"} + ], + "transient": True, + } + ) + ], + ) -class MockResponse: - def __init__(self, data, status_code): - self.json_data = json.loads(data) - self.status_code = status_code + # When & Then + flagsmith.get_identity_flags( + "identifier", + traits={"some_trait": "some_value"}, + transient=True, + ) - def json(self): - return self.json_data +@responses.activate() +def test_get_identity_flags__transient_trait_keys__calls_expected( + flagsmith: Flagsmith, + identities_json: str, + mocker: MockerFixture, +) -> None: + # Given + responses.add( + method="POST", + url=flagsmith.identities_url, + body=identities_json, + match=[ + matchers.json_params_matcher( + { + "identifier": "identifier", + "traits": [ + { + "trait_key": "some_trait", + "trait_value": "some_value", + "transient": True, + } + ], + }, + ) + ], + ) -def mock_response(filename, *args, status=200, **kwargs): - print("Hit URL %s with params" % args[0], kwargs.get("params")) - dir_path = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(dir_path, filename), "rt") as f: - return MockResponse(f.read(), status) + # When & Then + flagsmith.get_identity_flags( + "identifier", + traits={"some_trait": {"value": "some_value", "transient": True}}, + ) -def mocked_get_specific_feature_flag_enabled(*args, **kwargs): - return mock_response( - "data/get-flag-for-specific-feature-enabled.json", *args, **kwargs +def test_request_connection_error_raises_flagsmith_api_error( + mocker: MockerFixture, api_key: str +) -> None: + """ + Test the behaviour when session. raises a ConnectionError. Note that this + does not account for the fact that we are using retries. Since this is a standard + library, we leave this untested. It is assumed that, once the retries are exhausted, + the requests library raises requests.ConnectionError. + """ + + # Given + mock_session = mocker.MagicMock() + mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) + + flagsmith = Flagsmith(environment_key=api_key) + + mock_session.get.side_effect = requests.ConnectionError + + # When + with pytest.raises(FlagsmithAPIError): + flagsmith.get_environment_flags() + + # Then + # expected exception raised + + +@responses.activate() +def test_non_200_response_raises_flagsmith_api_error(flagsmith: Flagsmith) -> None: + # Given + responses.add(url=flagsmith.environment_flags_url, method="GET", status=400) + + # When + with pytest.raises(FlagsmithAPIError): + flagsmith.get_environment_flags() + + # Then + # expected exception raised + + +@pytest.mark.parametrize( + "settings, expected_timeout", + [ + ({"request_timeout_seconds": 5}, 5), # Arbitrary timeout + ({"request_timeout_seconds": None}, None), # No timeout is forced + ({}, 10), # Default timeout + ], +) +def test_request_times_out_according_to_setting( + mocker: MockerFixture, + api_key: str, + settings: typing.Dict[str, typing.Any], + expected_timeout: typing.Optional[int], +) -> None: + # Given + session = mocker.patch("flagsmith.flagsmith.requests.Session").return_value + flagsmith = Flagsmith( + environment_key=api_key, + enable_local_evaluation=False, + **settings, ) + # When + flagsmith.get_environment_flags() -def mocked_get_specific_feature_flag_disabled(*args, **kwargs): - return mock_response( - "data/get-flag-for-specific-feature-disabled.json", *args, **kwargs + # Then + session.get.assert_called_once_with( + "https://edge.api.flagsmith.com/api/v1/flags/", + json=None, + timeout=expected_timeout, ) -def mocked_get_specific_feature_flag_not_found(*args, **kwargs): - return mock_response("data/not-found.json", *args, status=404, **kwargs) +@responses.activate() +def test_default_flag_is_used_when_no_environment_flags_returned(api_key: str) -> None: + # Given + feature_name = "some_feature" + + # a default flag and associated handler + default_flag = DefaultFlag(True, "some-default-value") + def default_flag_handler(feature_name: str) -> DefaultFlag: + return default_flag -def mocked_get_value(*args, **kwargs): - return mock_response("data/get-value-for-specific-feature.json", *args, **kwargs) + flagsmith = Flagsmith( + environment_key=api_key, default_flag_handler=default_flag_handler + ) + # and we mock the API to return an empty list of flags + responses.add( + url=flagsmith.environment_flags_url, method="GET", body=json.dumps([]) + ) -def mocked_get_identity_flags_with_trait(*args, **kwargs): - return mock_response("data/get-identity-flags-with-trait.json", *args, **kwargs) + # When + flags = flagsmith.get_environment_flags() + # Then + # the data from the default flag is used + flag = flags.get_flag(feature_name) + assert flag.is_default + assert flag.enabled == default_flag.enabled + assert flag.value == default_flag.value -def mocked_get_identity_flags_without_trait(*args, **kwargs): - return mock_response("data/get-identity-flags-without-trait.json", *args, **kwargs) +@responses.activate() +def test_default_flag_is_not_used_when_environment_flags_returned( + api_key: str, flags_json: str +) -> None: + # Given + feature_name = "some_feature" -class FlagsmithTestCase(TestCase): - test_environment_key = "test-env-key" + # a default flag and associated handler + default_flag = DefaultFlag(True, "some-default-value") - def setUp(self) -> None: - self.bt = Flagsmith(environment_id=self.test_environment_key, api=TEST_API_URL) + def default_flag_handler(feature_name: str) -> DefaultFlag: + return default_flag - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_enabled, + flagsmith = Flagsmith( + environment_key=api_key, default_flag_handler=default_flag_handler ) - def test_has_feature_returns_true_if_feature_returned(self, mock_get): - # When - result = self.bt.has_feature(TEST_FEATURE) - # Then - assert result + # but we mock the API to return an actual value for the same feature + responses.add(url=flagsmith.environment_flags_url, method="GET", body=flags_json) + + # When + flags = flagsmith.get_environment_flags() - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_not_found, + # Then + # the data from the API response is used, not the default flag + flag = flags.get_flag(feature_name) + assert not flag.is_default + assert flag.value != default_flag.value + assert flag.value == "some-value" # hard coded value in tests/data/flags.json + + +@responses.activate() +def test_default_flag_is_used_when_no_identity_flags_returned(api_key: str) -> None: + # Given + feature_name = "some_feature" + + # a default flag and associated handler + default_flag = DefaultFlag(True, "some-default-value") + + def default_flag_handler(feature_name: str) -> DefaultFlag: + return default_flag + + flagsmith = Flagsmith( + environment_key=api_key, default_flag_handler=default_flag_handler ) - def test_has_feature_returns_false_if_feature_not_returned(self, mock_get): - # When - result = self.bt.has_feature(TEST_FEATURE) - # Then - assert not result + # and we mock the API to return an empty list of flags + response_data: typing.Mapping[str, typing.Sequence[typing.Any]] = { + "flags": [], + "traits": [], + } + responses.add( + url=flagsmith.identities_url, method="POST", body=json.dumps(response_data) + ) + + # When + flags = flagsmith.get_identity_flags(identifier="identifier") + + # Then + # the data from the default flag is used + flag = flags.get_flag(feature_name) + assert flag.is_default + assert flag.enabled == default_flag.enabled + assert flag.value == default_flag.value + + +@responses.activate() +def test_default_flag_is_not_used_when_identity_flags_returned( + api_key: str, identities_json: str +) -> None: + # Given + feature_name = "some_feature" + + # a default flag and associated handler + default_flag = DefaultFlag(True, "some-default-value") - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_enabled, + def default_flag_handler(feature_name: str) -> DefaultFlag: + return default_flag + + flagsmith = Flagsmith( + environment_key=api_key, default_flag_handler=default_flag_handler ) - def test_feature_enabled_returns_true_if_feature_enabled(self, mock_get): - # When - result = self.bt.feature_enabled(TEST_FEATURE) - # Then - assert result + # but we mock the API to return an actual value for the same feature + responses.add(url=flagsmith.identities_url, method="POST", body=identities_json) + + # When + flags = flagsmith.get_identity_flags(identifier="identifier") + + # Then + # the data from the API response is used, not the default flag + flag = flags.get_flag(feature_name) + assert not flag.is_default + assert flag.value != default_flag.value + assert flag.value == "some-value" # hard coded value in tests/data/identities.json + + +def test_default_flags_are_used_if_api_error_and_default_flag_handler_given( + mocker: MockerFixture, +) -> None: + # Given + # a default flag and associated handler + default_flag = DefaultFlag(True, "some-default-value") - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_disabled, + def default_flag_handler(feature_name: str) -> DefaultFlag: + return default_flag + + # but we mock the request session to raise a ConnectionError + mock_session = mocker.MagicMock() + mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) + mock_session.get.side_effect = requests.ConnectionError + + flagsmith = Flagsmith( + environment_key="some-key", default_flag_handler=default_flag_handler ) - def test_feature_enabled_returns_true_if_feature_disabled(self, mock_get): - # When - result = self.bt.feature_enabled(TEST_FEATURE) - # Then - assert not result + # When + flags = flagsmith.get_environment_flags() - @mock.patch("flagsmith.flagsmith.requests.get", side_effect=mocked_get_value) - def test_get_value_returns_value_for_environment_if_feature_exists(self, mock_get): - # When - result = self.bt.get_value(TEST_FEATURE) + # Then + assert flags.get_flag("some-feature") == default_flag + + +def test_get_identity_segments_no_traits( + local_eval_flagsmith: Flagsmith, +) -> None: + # Given + identifier = "identifier" + + # When + segments = local_eval_flagsmith.get_identity_segments(identifier) + + # Then + assert segments == [] + + +def test_get_identity_segments_with_valid_trait( + local_eval_flagsmith: Flagsmith, +) -> None: + # Given + identifier = "identifier" + traits = {"foo": "bar"} # obtained from data/environment.json + + # When + segments = local_eval_flagsmith.get_identity_segments(identifier, traits) + + # Then + assert len(segments) == 1 + assert segments[0].name == "Test segment" # obtained from data/environment.json + + +def test_get_identity_segments__identity_overrides__returns_expected( + local_eval_flagsmith: Flagsmith, +) -> None: + # Given + # the identifier matches the identity override in data/environment.json + identifier = "overridden-id" + # traits match the "Test segment" segment in data/environment.json + traits = {"foo": "bar"} + + # When + segments = local_eval_flagsmith.get_identity_segments(identifier, traits) + + # Then + # identity override virtual segment is not returned, + # only the segment matching the traits + assert len(segments) == 1 + assert segments[0].id == 1 + assert segments[0].name == "Test segment" + + +def test_local_evaluation_requires_server_key() -> None: + with pytest.raises(ValueError): + Flagsmith(environment_key="not-a-server-key", enable_local_evaluation=True) - # Then - assert result == "Test value" - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_not_found, +def test_initialise_flagsmith_with_proxies() -> None: + # Given + proxies = {"https": "https://my.proxy.com/proxy-me"} + + # When + flagsmith = Flagsmith(environment_key="test-key", proxies=proxies) + + # Then + assert flagsmith.session.proxies == proxies + + +def test_offline_mode(environment: EnvironmentModel) -> None: + # Given + class DummyOfflineHandler: + def get_environment(self) -> EnvironmentModel: + return environment + + # When + flagsmith = Flagsmith(offline_mode=True, offline_handler=DummyOfflineHandler()) + + # Then + # we can request the flags from the client successfully + environment_flags: Flags = flagsmith.get_environment_flags() + assert environment_flags.is_feature_enabled("some_feature") is True + + identity_flags: Flags = flagsmith.get_identity_flags("identity") + assert identity_flags.is_feature_enabled("some_feature") is True + + +@responses.activate() +def test_flagsmith_uses_offline_handler_if_set_and_no_api_response( + mocker: MockerFixture, + environment: EnvironmentModel, +) -> None: + # Given + api_url = "http://some.flagsmith.com/api/v1/" + mock_offline_handler = mocker.MagicMock(spec=OfflineHandler) + mock_offline_handler.get_environment.return_value = environment + + flagsmith = Flagsmith( + environment_key="some-key", + api_url=api_url, + offline_handler=mock_offline_handler, ) - def test_get_value_returns_None_for_environment_if_feature_does_not_exist( - self, mock_get - ): - # When - result = self.bt.get_value(TEST_FEATURE) - # Then - assert result is None + responses.get(flagsmith.environment_flags_url, status=500) + responses.get(flagsmith.identities_url, status=500) + + # When + environment_flags = flagsmith.get_environment_flags() + identity_flags = flagsmith.get_identity_flags("identity", traits={}) - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_identity_flags_with_trait, + # Then + mock_offline_handler.get_environment.assert_called_once_with() + + assert environment_flags.is_feature_enabled("some_feature") is True + assert environment_flags.get_feature_value("some_feature") == "some-value" + + assert identity_flags.is_feature_enabled("some_feature") is True + assert identity_flags.get_feature_value("some_feature") == "some-value" + + +@responses.activate() +def test_offline_mode__local_evaluation__correct_fallback( + mocker: MockerFixture, + environment: EnvironmentModel, + caplog: pytest.LogCaptureFixture, +) -> None: + # Given + api_url = "http://some.flagsmith.com/api/v1/" + mock_offline_handler = mocker.MagicMock(spec=OfflineHandler) + mock_offline_handler.get_environment.return_value = environment + + mocker.patch("flagsmith.flagsmith.EnvironmentDataPollingManager") + + responses.get(api_url + "environment-document/", status=500) + + flagsmith = Flagsmith( + environment_key="ser.some-key", + api_url=api_url, + enable_local_evaluation=True, + offline_handler=mock_offline_handler, ) - def test_get_trait_returns_trait_value_if_trait_key_exists(self, mock_get): + + # When + environment_flags = flagsmith.get_environment_flags() + identity_flags = flagsmith.get_identity_flags("identity", traits={}) + + # Then + mock_offline_handler.get_environment.assert_called_once_with() + + assert environment_flags.is_feature_enabled("some_feature") is True + assert environment_flags.get_feature_value("some_feature") == "some-value" + + assert identity_flags.is_feature_enabled("some_feature") is True + assert identity_flags.get_feature_value("some_feature") == "some-value" + + [error_log_record] = caplog.records + assert error_log_record.levelname == "ERROR" + assert error_log_record.message == "Error retrieving environment document from API" + + +def test_cannot_use_offline_mode_without_offline_handler() -> None: + with pytest.raises(ValueError) as e: # When - result = self.bt.get_trait("trait_key", TEST_IDENTIFIER) + Flagsmith(offline_mode=True, offline_handler=None) + + # Then + assert ( + e.exconly() + == "ValueError: offline_handler must be provided to use offline mode." + ) - # Then - assert result == "trait_value" - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_identity_flags_without_trait, +def test_cannot_use_default_handler_and_offline_handler(mocker: MockerFixture) -> None: + # When + with pytest.raises(ValueError) as e: + Flagsmith( + offline_handler=mocker.MagicMock(spec=OfflineHandler), + default_flag_handler=lambda flag_name: DefaultFlag( + enabled=True, value="foo" + ), + ) + + # Then + assert ( + e.exconly() + == "ValueError: Cannot use both default_flag_handler and offline_handler." ) - def test_get_trait_returns_None_if_trait_key_does_not_exist(self, mock_get): - # When - result = self.bt.get_trait("trait_key", TEST_IDENTIFIER) - # Then - assert result is None + +def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key() -> None: + # When + with pytest.raises(ValueError) as e: + Flagsmith() + + # Then + assert e.exconly() == "ValueError: environment_key is required." + + +def test_stream_not_used_by_default( + requests_session_response_ok: None, server_api_key: str +) -> None: + # When + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + ) + + # Then + assert hasattr(flagsmith, "event_stream_thread") is False + + +def test_stream_used_when_enable_realtime_updates_is_true( + requests_session_response_ok: None, server_api_key: str +) -> None: + # When + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + enable_realtime_updates=True, + ) + + # Then + assert hasattr(flagsmith, "event_stream_thread") is True + + +def test_error_raised_when_realtime_updates_is_true_and_local_evaluation_false( + requests_session_response_ok: None, server_api_key: str +) -> None: + with pytest.raises(ValueError): + Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=False, + enable_realtime_updates=True, + ) + + +@responses.activate() +def test_flagsmith_client_get_identity_flags__local_evaluation__returns_expected( + environment_json: str, + server_api_key: str, +) -> None: + # Given + identifier = "overridden-id" + + api_url = "https://mocked.flagsmith.com/api/v1/" + environment_document_url = f"{api_url}environment-document/" + responses.add(method="GET", url=environment_document_url, body=environment_json) + + flagsmith = Flagsmith( + environment_key=server_api_key, + api_url=api_url, + enable_local_evaluation=True, + ) + time.sleep(0.1) + + # When + flag = flagsmith.get_identity_flags(identifier).get_flag("some_feature") + + # Then + assert flag.enabled is False + assert flag.value == "some-overridden-value" + + +def test_custom_feature_error_raised_when_invalid_feature( + requests_session_response_ok: None, server_api_key: str +) -> None: + # Given + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + ) + + flags = flagsmith.get_environment_flags() + + # Then + with pytest.raises(FlagsmithFeatureDoesNotExistError): + # When + flags.is_feature_enabled("non-existing-feature") + + +@pytest.mark.parametrize( + "kwargs,expected_headers", + [ + ( + { + "environment_key": "test-key", + "application_metadata": {"name": "test-app", "version": "1.0.0"}, + }, + { + "Flagsmith-Application-Name": "test-app", + "Flagsmith-Application-Version": "1.0.0", + "X-Environment-Key": "test-key", + }, + ), + ( + { + "environment_key": "test-key", + "application_metadata": {"name": "test-app"}, + }, + { + "Flagsmith-Application-Name": "test-app", + "X-Environment-Key": "test-key", + }, + ), + ( + { + "environment_key": "test-key", + "application_metadata": {"version": "1.0.0"}, + }, + { + "Flagsmith-Application-Version": "1.0.0", + "X-Environment-Key": "test-key", + }, + ), + ( + { + "environment_key": "test-key", + "application_metadata": {"version": "1.0.0"}, + "custom_headers": {"X-Custom-Header": "CustomValue"}, + }, + { + "Flagsmith-Application-Version": "1.0.0", + "X-Environment-Key": "test-key", + "X-Custom-Header": "CustomValue", + }, + ), + ( + { + "environment_key": "test-key", + "application_metadata": None, + "custom_headers": {"X-Custom-Header": "CustomValue"}, + }, + { + "X-Environment-Key": "test-key", + "X-Custom-Header": "CustomValue", + }, + ), + ( + {"environment_key": "test-key"}, + { + "X-Environment-Key": "test-key", + }, + ), + ], +) +@responses.activate() +def test_flagsmith__init__expected_headers_sent( + kwargs: typing.Dict[str, typing.Any], + expected_headers: typing.Dict[str, str], +) -> None: + # Given + flagsmith = Flagsmith(**kwargs) + responses.add(method="GET", url=flagsmith.environment_flags_url, body="{}") + + # When + flagsmith.get_environment_flags() + + # Then + headers = responses.calls[0].request.headers + assert headers == { + "User-Agent": f"flagsmith-python-sdk/{__version__}", + "Accept-Encoding": "gzip, deflate", + "Accept": "*/*", + "Connection": "keep-alive", + **expected_headers, + } diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c992395 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,151 @@ +import typing + +import pytest + +from flagsmith.models import Flag, Flags +from flagsmith.types import SDKEvaluationResult, SDKFlagResult + + +def test_flag_from_evaluation_result() -> None: + # Given + flag_result: SDKFlagResult = { + "enabled": True, + "name": "test_feature", + "reason": "DEFAULT", + "value": "test-value", + "metadata": {"id": 123}, + } + + # When + flag = Flag.from_evaluation_result(flag_result) + + # Then + assert flag.enabled is True + assert flag.value == "test-value" + assert flag.feature_name == "test_feature" + assert flag.feature_id == 123 + assert flag.is_default is False + + +@pytest.mark.parametrize( + "flags_result,expected_names", + [ + ({}, []), + ( + { + "feature1": { + "enabled": True, + "name": "feature1", + "reason": "DEFAULT", + "value": "value1", + "metadata": {"id": 1}, + } + }, + ["feature1"], + ), + ( + { + "feature1": { + "enabled": True, + "name": "feature1", + "reason": "DEFAULT", + "value": "value1", + "metadata": {"id": 1}, + } + }, + ["feature1"], + ), + ( + { + "feature1": { + "enabled": True, + "name": "feature1", + "reason": "DEFAULT", + "value": "value1", + "metadata": {"id": 1}, + }, + "feature2": { + "enabled": True, + "name": "feature2", + "reason": "DEFAULT", + "value": "value2", + "metadata": {"id": 2}, + }, + "feature3": { + "enabled": True, + "name": "feature3", + "reason": "DEFAULT", + "value": 42, + "metadata": {"id": 3}, + }, + }, + ["feature1", "feature2", "feature3"], + ), + ], +) +def test_flags_from_evaluation_result( + flags_result: typing.Dict[str, SDKFlagResult], + expected_names: typing.List[str], +) -> None: + # Given + evaluation_result: SDKEvaluationResult = { + "flags": flags_result, + "segments": [], + } + + # When + flags: Flags = Flags.from_evaluation_result( + evaluation_result=evaluation_result, + analytics_processor=None, + default_flag_handler=None, + ) + + # Then + assert set(flags.flags.keys()) == set(expected_names) + assert set(flag.feature_name for flag in flags.flags.values()) == set( + expected_names + ) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("string", "string"), + (42, 42), + (3.14, 3.14), + (True, True), + (False, False), + (None, None), + ], +) +def test_flag_from_evaluation_result_value_types( + value: typing.Any, expected: typing.Any +) -> None: + # Given + flag_result: SDKFlagResult = { + "enabled": True, + "name": "test_feature", + "reason": "DEFAULT", + "value": value, + "metadata": {"id": 123}, + } + + # When + flag = Flag.from_evaluation_result(flag_result) + + # Then + assert flag.value == expected + + +def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None: + # Given + flag_result: SDKFlagResult = { + "enabled": True, + "name": "test_feature", + "reason": "DEFAULT", + "value": "test-value", + } + + # When & Then + with pytest.raises(ValueError): + Flag.from_evaluation_result(flag_result) diff --git a/tests/test_offline_handlers.py b/tests/test_offline_handlers.py new file mode 100644 index 0000000..81e9cda --- /dev/null +++ b/tests/test_offline_handlers.py @@ -0,0 +1,34 @@ +import json + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + +from flagsmith.offline_handlers import LocalFileHandler + + +def test_local_file_handler( + fs: FakeFilesystem, + environment_json: str, +) -> None: + # Given + environment_document_file_path = "/some/path/environment.json" + fs.create_file(environment_document_file_path, contents=environment_json) + local_file_handler = LocalFileHandler(environment_document_file_path) + + # When + result = local_file_handler.get_environment() + + # Then + assert result == json.loads(environment_json) + + +def test_local_file_handler__invalid_contents__raises_expected( + fs: FakeFilesystem, +) -> None: + # Given + environment_document_file_path = "/some/path/environment.json" + fs.create_file(environment_document_file_path, contents="{}") + + # When & Then + with pytest.raises(KeyError): + LocalFileHandler(environment_document_file_path) diff --git a/tests/test_polling_manager.py b/tests/test_polling_manager.py new file mode 100644 index 0000000..e5ccb2d --- /dev/null +++ b/tests/test_polling_manager.py @@ -0,0 +1,93 @@ +import time +from unittest import mock + +import requests +import responses +from pytest_mock import MockerFixture + +from flagsmith import Flagsmith +from flagsmith.polling_manager import EnvironmentDataPollingManager + + +def test_polling_manager_calls_update_environment_on_start() -> None: + # Given + flagsmith = mock.MagicMock() + polling_manager = EnvironmentDataPollingManager( + main=flagsmith, refresh_interval_seconds=0.1 + ) + + # When + polling_manager.start() + + # Then + flagsmith.update_environment.assert_called_once() + polling_manager.stop() + + +def test_polling_manager_calls_update_environment_on_each_refresh() -> None: + # Given + flagsmith = mock.MagicMock() + polling_manager = EnvironmentDataPollingManager( + main=flagsmith, refresh_interval_seconds=0.1 + ) + + # When + polling_manager.start() + time.sleep(0.25) + + # Then + # 3 calls to update_environment should be made, one when the thread starts and 2 + # for each subsequent refresh + assert flagsmith.update_environment.call_count == 3 + polling_manager.stop() + + +@responses.activate() +def test_polling_manager_is_resilient_to_api_errors( + flagsmith: Flagsmith, + environment_json: str, + mocker: MockerFixture, + server_api_key: str, +) -> None: + # Given + responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + environment_refresh_interval_seconds=0.1, + ) + + responses.add(method="GET", url=flagsmith.environment_url, status=500) + polling_manager = flagsmith.environment_data_polling_manager_thread + + # Then + assert polling_manager.is_alive() + polling_manager.stop() + + +@responses.activate() +def test_polling_manager_is_resilient_to_request_exceptions( + flagsmith: Flagsmith, + environment_json: str, + mocker: MockerFixture, + server_api_key: str, +) -> None: + # Given + responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + environment_refresh_interval_seconds=0.1, + ) + + responses.add( + method="GET", + url=flagsmith.environment_url, + body=requests.RequestException("Some exception"), + status=500, + ) + polling_manager = flagsmith.environment_data_polling_manager_thread + + # Then + assert polling_manager.is_alive() + polling_manager.stop() diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py new file mode 100644 index 0000000..9b2c249 --- /dev/null +++ b/tests/test_streaming_manager.py @@ -0,0 +1,85 @@ +import time +from datetime import datetime, timezone +from unittest.mock import MagicMock, Mock + +import requests +import responses +from pytest_mock import MockerFixture + +from flagsmith import Flagsmith +from flagsmith.streaming_manager import EventStreamManager +from flagsmith.types import StreamEvent + + +def test_stream_manager_handles_timeout( + mocked_responses: responses.RequestsMock, +) -> None: + stream_url = ( + "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" + ) + + mocked_responses.get(stream_url, body=requests.exceptions.ReadTimeout()) + + streaming_manager = EventStreamManager( + stream_url=stream_url, + on_event=MagicMock(), + daemon=True, + ) + + streaming_manager.start() + + time.sleep(0.01) + + assert streaming_manager.is_alive() + + streaming_manager.stop() + + +def test_environment_updates_on_recent_event( + server_api_key: str, mocker: MockerFixture +) -> None: + stream_updated_at = datetime(2020, 1, 1, 1, 1, 2, tzinfo=timezone.utc) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 1, tzinfo=timezone.utc) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + flagsmith = Flagsmith(environment_key=server_api_key) + flagsmith._evaluation_context = MagicMock() + flagsmith._environment_updated_at = environment_updated_at + flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) + assert isinstance(flagsmith.update_environment, Mock) + flagsmith.update_environment.assert_called_once() + + +def test_environment_does_not_update_on_past_event( + server_api_key: str, mocker: MockerFixture +) -> None: + stream_updated_at = datetime(2020, 1, 1, 1, 1, 1, tzinfo=timezone.utc) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 2, tzinfo=timezone.utc) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + flagsmith = Flagsmith(environment_key=server_api_key) + flagsmith._evaluation_context = MagicMock() + flagsmith._environment_updated_at = environment_updated_at + + flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) + assert isinstance(flagsmith.update_environment, Mock) + flagsmith.update_environment.assert_not_called() + + +def test_environment_does_not_update_on_same_event( + server_api_key: str, mocker: MockerFixture +) -> None: + stream_updated_at = datetime(2020, 1, 1, 1, 1, 1, tzinfo=timezone.utc) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 1, tzinfo=timezone.utc) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + flagsmith = Flagsmith(environment_key=server_api_key) + flagsmith._evaluation_context = MagicMock() + flagsmith._environment_updated_at = environment_updated_at + + flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) + assert isinstance(flagsmith.update_environment, Mock) + flagsmith.update_environment.assert_not_called() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..5ac7ea6 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,50 @@ +import json + +from flagsmith.webhooks import generate_signature, verify_signature + + +def test_generate_signature() -> None: + # Given + request_body = json.dumps({"data": {"foo": 123}}) + shared_secret = "shh" + + # When + signature = generate_signature(request_body, shared_secret) + + # Then + assert isinstance(signature, str) + assert len(signature) == 64 # SHA-256 hex digest is 64 characters + + +def test_verify_signature_valid() -> None: + # Given + request_body = json.dumps({"data": {"foo": 123}}) + shared_secret = "shh" + + # When + signature = generate_signature(request_body, shared_secret) + + # Then + assert verify_signature( + request_body=request_body, + received_signature=signature, + shared_secret=shared_secret, + ) + # Test with bytes instead of str + assert verify_signature( + request_body=request_body.encode(), + received_signature=signature, + shared_secret=shared_secret, + ) + + +def test_verify_signature_invalid() -> None: + # Given + request_body = json.dumps({"event": "flag_updated", "data": {"id": 123}}) + + # Then + assert not verify_signature( + request_body=request_body, + received_signature="bad", + shared_secret="?", + )