├── .github ├── dependabot.yml └── workflows │ ├── benchmark.yml │ ├── check-semantic.yml │ ├── lint.yml │ ├── release.yml │ ├── run-test-harness.yml │ ├── test_examples.yml │ └── unit_test.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── OpenFeature.md ├── README.md ├── devcycle_python_sdk ├── VERSION.txt ├── __init__.py ├── api │ ├── __init__.py │ ├── backoff.py │ ├── bucketing_client.py │ ├── config_client.py │ ├── event_client.py │ └── local_bucketing.py ├── bucketing-lib.release.wasm ├── cloud_client.py ├── devcycle_client.py ├── exceptions.py ├── local_client.py ├── managers │ ├── __init__.py │ ├── config_manager.py │ ├── event_queue_manager.py │ └── sse_manager.py ├── models │ ├── __init__.py │ ├── bucketed_config.py │ ├── error_response.py │ ├── event.py │ ├── feature.py │ ├── platform_data.py │ ├── user.py │ └── variable.py ├── open_feature_provider │ ├── __init__.py │ └── provider.py ├── options.py ├── protobuf │ ├── __init__.py │ ├── utils.py │ ├── variableForUserParams_pb2.py │ └── variableForUserParams_pb2.pyi ├── py.typed └── util │ ├── __init__.py │ ├── strings.py │ └── version.py ├── example ├── __init__.py ├── cloud_client_example.py ├── django-app │ ├── README.md │ ├── config │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── devcycle_test │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── middleware.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── urls.py │ │ └── views.py │ ├── manage.py │ └── requirements.txt ├── local_bucketing_client_example.py └── openfeature_example.py ├── protobuf └── variableForUserParams.proto ├── pyproject.toml ├── requirements.test.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── test ├── __init__.py ├── api │ ├── __init__.py │ ├── test_bucketing_client.py │ ├── test_config_client.py │ ├── test_event_client.py │ └── test_local_bucketing.py ├── fixture │ ├── __init__.py │ ├── data.py │ ├── fixture_bucketed_config.json │ ├── fixture_bucketed_config_minimal.json │ ├── fixture_large_config.json │ ├── fixture_small_config.json │ └── fixture_small_config_special_characters.json ├── managers │ ├── __init__.py │ ├── test_config_manager.py │ └── test_event_queue_manager.py ├── models │ ├── __init__.py │ ├── test_bucketed_config.py │ └── test_user.py ├── openfeature_test │ ├── __init__.py │ ├── test_provider.py │ └── test_provider_local_sdk.py ├── test_cloud_client.py ├── test_local_client.py └── util │ ├── __init__.py │ ├── test_strings.py │ ├── test_utils.py │ └── test_version.py └── update_wasm_lib.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | reviewers: 8 | - "devcyclehq/engineering" 9 | versioning-strategy: increase 10 | - package-ecosystem: pip 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | reviewers: 15 | - "devcyclehq/engineering" 16 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | benchmark: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ "3.12" ] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | cache: 'pip' 19 | - name: Install dependencies 20 | run: | 21 | pip install --upgrade pip 22 | pip install -r requirements.test.txt 23 | - name: Run benchmarks 24 | run: | 25 | pytest --benchmark-only --benchmark-json=benchmarks.json 26 | - name: Print summary 27 | run: | 28 | echo "### Benchmark Results" >> $GITHUB_STEP_SUMMARY 29 | echo " 30 | Benchmark|Min (uS)|Median (uS)|Mean (uS)|Max (uS)|Iterations 31 | ---|---|---|---|---|--- 32 | $(jq -r '.benchmarks[] | [.name,(.stats.min*1000000000 | round / 1000),(.stats.median*1000000000 | round / 1000),(.stats.mean*1000000000 | round / 1000),(.stats.max*1000000000 | round / 1000),.stats.rounds] | join("|")' benchmarks.json) 33 | " >> $GITHUB_STEP_SUMMARY -------------------------------------------------------------------------------- /.github/workflows/check-semantic.yml: -------------------------------------------------------------------------------- 1 | name: Semantic PR 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | permissions: 9 | pull-requests: read 10 | jobs: 11 | check_title: 12 | name: Semantic PR title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: pull_request 3 | 4 | jobs: 5 | lint: 6 | name: Lint and Format 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Lint 12 | uses: chartboost/ruff-action@v1 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.12 18 | cache: 'pip' 19 | 20 | - name: Install dependencies 21 | run: | 22 | pip install --upgrade pip 23 | pip install -r requirements.test.txt 24 | 25 | - name: Check formatting 26 | run: | 27 | black --check . 28 | 29 | - name: Run mypy 30 | run: | 31 | mypy . -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | prerelease: 7 | description: "Prerelease" 8 | required: true 9 | default: false 10 | type: boolean 11 | draft: 12 | description: "Draft" 13 | required: true 14 | default: false 15 | type: boolean 16 | version-increment-type: 17 | description: 'Which part of the version to increment:' 18 | required: true 19 | type: choice 20 | options: 21 | - major 22 | - minor 23 | - patch 24 | default: 'patch' 25 | 26 | permissions: 27 | contents: write 28 | 29 | jobs: 30 | release: 31 | runs-on: ubuntu-latest 32 | permissions: 33 | contents: write 34 | id-token: write 35 | 36 | strategy: 37 | matrix: 38 | python-version: [ "3.12" ] 39 | 40 | steps: 41 | # Check out the repo with credentials that can bypass branch protection, and fetch git history instead of just latest commit 42 | - uses: actions/checkout@v4 43 | with: 44 | token: ${{ secrets.AUTOMATION_USER_TOKEN }} 45 | fetch-depth: 0 46 | 47 | - uses: DevCycleHQ/release-action/prepare-release@v2.3.0 48 | id: prepare-release 49 | with: 50 | github-token: ${{ secrets.AUTOMATION_USER_TOKEN }} 51 | prerelease: ${{ github.event.inputs.prerelease }} 52 | draft: ${{ github.event.inputs.draft }} 53 | version-increment-type: ${{ github.event.inputs.version-increment-type }} 54 | 55 | - name: Update version in code 56 | run: | 57 | echo "${{steps.prepare-release.outputs.next-release-tag}}" > devcycle_python_sdk/VERSION.txt 58 | 59 | - name: Commit version change 60 | run: | 61 | git config --global user.email "foundation-admin@devcycle.com" 62 | git config --global user.name "DevCycle Automation" 63 | git add ./devcycle_python_sdk/VERSION.txt 64 | git commit -m "Release ${{steps.prepare-release.outputs.next-release-tag}}" 65 | 66 | - name: Push version change 67 | run: | 68 | git push origin HEAD:main 69 | if: inputs.draft != true 70 | 71 | - name: Set up Python 72 | uses: actions/setup-python@v5 73 | with: 74 | python-version: ${{ matrix.python-version }} 75 | cache: 'pip' 76 | 77 | - name: Install dependencies 78 | run: | 79 | pip install --upgrade pip 80 | pip install twine wheel setuptools 81 | 82 | - name: Build and validate package 83 | run: | 84 | python setup.py sdist bdist_wheel 85 | twine check dist/* 86 | 87 | - name: Upload build package to PyPI 88 | uses: pypa/gh-action-pypi-publish@release/v1 89 | if: inputs.prerelease != true && inputs.draft != true 90 | 91 | - uses: DevCycleHQ/release-action/create-release@v2.3.0 92 | id: create-release 93 | with: 94 | github-token: ${{ secrets.GITHUB_TOKEN }} 95 | tag: ${{ steps.prepare-release.outputs.next-release-tag }} 96 | target: main 97 | prerelease: ${{ github.event.inputs.prerelease }} 98 | draft: ${{ github.event.inputs.draft }} 99 | changelog: ${{ steps.prepare-release.outputs.changelog }} 100 | 101 | # TODO: upload python release artifacts 102 | 103 | - name: Display link to release 104 | run: | 105 | echo "::notice title=Release ID::${{ steps.create-release.outputs.release-id }}" 106 | echo "::notice title=Release URL::${{ steps.create-release.outputs.release-url }}" -------------------------------------------------------------------------------- /.github/workflows/run-test-harness.yml: -------------------------------------------------------------------------------- 1 | name: Run Test Harness 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | harness-tests: 9 | name: Harness Tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: DevCycleHQ/test-harness@main 13 | env: 14 | SDK_CAPABILITIES: '["clientCustomData","v2Config","EdgeDB","CloudBucketing"]' 15 | with: 16 | sdks-to-test: python 17 | sdk-github-sha: ${{github.event.pull_request.head.sha}} 18 | -------------------------------------------------------------------------------- /.github/workflows/test_examples.yml: -------------------------------------------------------------------------------- 1 | name: Test Examples 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | test_examples: 7 | name: Test Examples 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [ "3.12" ] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | cache: 'pip' 20 | - name: Install dependencies 21 | run: | 22 | pip install --upgrade pip 23 | pip install -e . 24 | - name: Run local bucketing example 25 | run: | 26 | cd example && python local_bucketing_client_example.py 27 | env: 28 | DEVCYCLE_SERVER_SDK_KEY: dvc_server_token_hash 29 | - name: Run cloud bucketing example 30 | run: | 31 | cd example && python cloud_client_example.py 32 | env: 33 | DEVCYCLE_SERVER_SDK_KEY: dvc_server_token_hash 34 | - name: Run OpenFeature example 35 | run: | 36 | cd example && python openfeature_example.py 37 | env: 38 | DEVCYCLE_SERVER_SDK_KEY: dvc_server_token_hash -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | unit_tests: 7 | name: Unit Tests 8 | runs-on: ${{matrix.os}} 9 | strategy: 10 | matrix: 11 | python-version: ["3.12", "3.9"] 12 | os: [ubuntu-latest, windows-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | cache: "pip" 21 | - name: Install dependencies 22 | run: | 23 | pip install --upgrade pip 24 | pip install -r requirements.test.txt 25 | - name: Run unit tests 26 | run: | 27 | pytest 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | venv/ 48 | .python-version 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | #Ipython Notebook 64 | .ipynb_checkpoints 65 | 66 | .idea 67 | .DS_STORE 68 | 69 | # Benchmark output 70 | .benchmarks 71 | benchmark.json 72 | 73 | pip-wheel-metadata/ 74 | .venv/ 75 | 76 | # Virtual environments 77 | venv*/ 78 | .venv*/ 79 | *venv/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.travis-ci.com/user/languages/python 2 | language: python 3 | python: 4 | - "3.2" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | #- "3.5-dev" # 3.5 development branch 9 | #- "nightly" # points to the latest development branch e.g. 3.6-dev 10 | # command to install dependencies 11 | install: "pip install -r requirements.txt" 12 | # command to run tests 13 | script: nosetests 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Taplytics Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OpenFeature.md: -------------------------------------------------------------------------------- 1 | # DevCycle Python SDK OpenFeature Provider 2 | 3 | This SDK provides a Python implementation of the [OpenFeature](https://openfeature.dev/) Provider interface. 4 | 5 | ## Example App 6 | 7 | See the [example app](/example/openfeature_example.py) for a working example using DevCycle Python SDK OpenFeature Provider. 8 | 9 | ## Usage 10 | 11 | See our [documentation](https://docs.devcycle.com/sdk/server-side-sdks/python) for more information. 12 | 13 | Instantiate and configure the DevCycle SDK client first, either `DevCycleLocalClient` or `DevCycleCloudClient`. 14 | 15 | Once the DevCycle client is configured, call the `devcycle_client.get_openfeature_provider()` function to obtain the OpenFeature provider. 16 | 17 | ```python 18 | from openfeature import api 19 | from openfeature.evaluation_context import EvaluationContext 20 | 21 | from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions 22 | 23 | # Initialize the DevCycle SDK 24 | devcycle_client = DevCycleLocalClient("DEVCYCLE_SERVER_SDK_KEY", DevCycleLocalOptions()) 25 | 26 | # Set the initialzed DevCycle client as the provider for OpenFeature 27 | api.set_provider(devcycle_client.get_openfeature_provider()) 28 | 29 | # Get the OpenFeature client 30 | open_feature_client = api.get_client() 31 | 32 | # Set the global context for the OpenFeature client, you can use "targetingKey" or "user_id" 33 | # This can also be done on a request basis instead 34 | api.set_evaluation_context(EvaluationContext(targeting_key="test-1234")) 35 | 36 | # Retrieve a boolean flag from the OpenFeature client 37 | bool_flag = open_feature_client.get_boolean_value("bool-flag", False) 38 | ``` 39 | 40 | #### Required Targeting Key 41 | 42 | For DevCycle SDK to work we require either a `targeting_key` or `user_id` attribute to be set on the OpenFeature context. 43 | This value is used to identify the user as the `user_id` property for a `DevCycleUser` in DevCycle. 44 | 45 | ### Mapping Context Properties to DevCycleUser 46 | 47 | The provider will automatically translate known `DevCycleUser` properties from the OpenFeature context to the [`DevCycleUser`](https://github.com/DevCycleHQ/python-server-sdk/blob/main/devcycle_python_sdk/models/user.py#L8) object for use in targeting and segmentation. 48 | 49 | For example all these properties will be set on the `DevCycleUser`: 50 | ```python 51 | context = EvaluationContext(targeting_key="test-1234", attributes={ 52 | "email": "test-user@domain.com", 53 | "name": "Test User", 54 | "language": "en", 55 | "country": "CA", 56 | "appVersion": "1.0.11", 57 | "appBuild": 1, 58 | "customData": {"custom": "data"}, 59 | "privateCustomData": {"private": "data"} 60 | }) 61 | ``` 62 | 63 | Context attributes that do not map to known `DevCycleUser` properties will be automatically 64 | added to the `customData` dictionary of the `DevCycleUser` object. 65 | 66 | DevCycle allows the following data types for custom data values: **boolean**, **integer**, **float**, and **str**. Other data types will be ignored 67 | 68 | #### Context Limitations 69 | 70 | DevCycle only supports flat JSON Object properties used in the Context. Non-flat properties will be ignored. 71 | 72 | For example `obj` will be ignored: 73 | ```python 74 | context = EvaluationContext(targeting_key="test-1234", attributes={ 75 | "obj": { "key": "value" } 76 | }) 77 | ``` 78 | 79 | #### JSON Flag Limitations 80 | 81 | The OpenFeature spec for JSON flags allows for any type of valid JSON value to be set as the flag value. 82 | 83 | For example the following are all valid default value types to use with OpenFeature: 84 | ```python 85 | # Invalid JSON values for the DevCycle SDK, will return defaults 86 | open_feature_client.get_object_value("json-flag", ["array"]) 87 | open_feature_client.get_object_value("json-flag", 610) 88 | open_feature_client.get_object_value("json-flag", false) 89 | open_feature_client.get_object_value("json-flag", "string") 90 | open_feature_client.get_object_value("json-flag", None) 91 | ``` 92 | 93 | However, these are not valid types for the DevCycle SDK, the DevCycle SDK only supports JSON Objects: 94 | ```python 95 | # Valid JSON Object as the default value, will be evaluated by the DevCycle SDK 96 | open_feature_client.get_object_value("json-flag", { "default": "value" }) 97 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DevCycle Python Server SDK 2 | 3 | The DevCycle Python SDK used for feature management. 4 | 5 | This SDK allows your application to interface with the [DevCycle Bucketing API](https://docs.devcycle.com/bucketing-api/#tag/devcycle). 6 | 7 | ## Requirements 8 | 9 | * Python 3.9+ 10 | 11 | ## Installation 12 | 13 | ```sh 14 | pip install devcycle-python-server-sdk 15 | ``` 16 | 17 | (you may need to run `pip` with root permission: `sudo pip install devcycle-python-server-sdk`) 18 | 19 | ## Getting Started 20 | 21 | The core DevCycle objects are in the `devcycle_python_sdk` package. To get started, import the `DevCycleLocalClient` class and the `DevCycleLocalOptions` class. The `DevCycleLocalClient` class is used to interact with the DevCycle API. The `DevCycleLocalOptions` class is used to configure the client. 22 | 23 | ```python 24 | from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions 25 | from devcycle_python_sdk.models.user import DevCycleUser 26 | 27 | options = DevCycleLocalOptions() 28 | 29 | # create an instance of the client class 30 | client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options) 31 | 32 | user = DevCycleUser( 33 | user_id='test', 34 | email='example@example.ca', 35 | country='CA' 36 | ) 37 | 38 | value = client.variable_value(user, 'variable-key', 'default-value') 39 | ``` 40 | 41 | The DevCycle client is designed to work as a singleton in your application. You should create a single instance of the client during application initialization 42 | 43 | ## OpenFeature Support 44 | 45 | This SDK provides an alpha implementation of the [OpenFeature](https://openfeature.dev/) Provider interface. Use the `get_openfeature_provider()` function on the DevCycle SDK client to obtain a provider for OpenFeature. 46 | 47 | ```python 48 | from openfeature import api 49 | 50 | devcycle_client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options) 51 | api.set_provider(devcycle_client.get_openfeature_provider()) 52 | ``` 53 | 54 | More details are in the [DevCycle Python SDK OpenFeature Provider](OpenFeature.md) guide. 55 | 56 | > :warning: **OpenFeature support is in an early release and may have some rough edges**. Please report any issues to us and we'll be happy to help! 57 | 58 | ## Usage 59 | 60 | To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage). 61 | 62 | ## Development 63 | 64 | When developing the SDK it is recommended that you have both a 3.8 and 3.12 python interpreter installed in order to verify changes across different versions of python. 65 | 66 | ### Dependencies 67 | 68 | To set up dependencies for local development, run: 69 | 70 | ```bash 71 | pip install -r requirements.test.txt 72 | ``` 73 | 74 | To run the example app against the local version of the API for testing and development, run: 75 | 76 | ```bash 77 | pip install --editable . 78 | ``` 79 | 80 | from the top level of the repo (same level as setup.py). Then run the example app as normal: 81 | 82 | ```bash 83 | python example/local_bucketing_client_example.py 84 | ``` 85 | 86 | ### Linting & Formatting 87 | 88 | Linting checks on PRs are run using [ruff](https://github.com/charliermarsh/ruff), and are configured using `.ruff.toml`. To run the linter locally, run this command from the top level of the repo: 89 | 90 | ```bash 91 | ruff check . 92 | ``` 93 | 94 | Ruff can automatically fix simple linting errors (the ones marked with `[*]`). To do so, run: 95 | 96 | ```bash 97 | ruff check . --fix 98 | ``` 99 | 100 | Formatting checks on PRs are done using [black](https://github.com/psf/black). To run the formatter locally, run this command from the top level of the repo: 101 | 102 | ```bash 103 | black . 104 | ``` 105 | 106 | ### Unit Tests 107 | 108 | To run the unit tests, run: 109 | 110 | ```bash 111 | pytest 112 | ``` 113 | 114 | ### Benchmarks 115 | 116 | To run the benchmarks, run: 117 | 118 | ```bash 119 | pytest --benchmark-only 120 | ``` 121 | 122 | ### Protobuf Code Generation 123 | 124 | To generate the protobuf source files run the following from the root of the project. Ensure you have `protoc` installed. 125 | 126 | ```bash 127 | protoc --proto_path=./protobuf/ --python_out=devcycle_python_sdk/protobuf --pyi_out=devcycle_python_sdk/protobuf variableForUserParams.proto 128 | ``` 129 | 130 | This will rebuild the `variableForUserParams_pb2.py` file. DO NOT edit this file directly. 131 | -------------------------------------------------------------------------------- /devcycle_python_sdk/VERSION.txt: -------------------------------------------------------------------------------- 1 | 3.11.3 2 | -------------------------------------------------------------------------------- /devcycle_python_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | # Simplify imports for the SDK entry point objects 2 | 3 | from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions 4 | from devcycle_python_sdk.devcycle_client import AbstractDevCycleClient 5 | from devcycle_python_sdk.cloud_client import DevCycleCloudClient 6 | from devcycle_python_sdk.local_client import DevCycleLocalClient 7 | -------------------------------------------------------------------------------- /devcycle_python_sdk/api/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /devcycle_python_sdk/api/backoff.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | 5 | def exponential_backoff(attempt: int, base_delay: float) -> float: 6 | """ 7 | Exponential backoff starting with 200ms +- 0...40ms jitter 8 | """ 9 | delay = math.pow(2, attempt) * base_delay / 2.0 10 | random_sum = delay * 0.1 * random.random() 11 | return delay + random_sum 12 | -------------------------------------------------------------------------------- /devcycle_python_sdk/api/bucketing_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Dict, List, Optional 4 | 5 | import requests 6 | 7 | from devcycle_python_sdk.api.backoff import exponential_backoff 8 | from devcycle_python_sdk.options import DevCycleCloudOptions 9 | from devcycle_python_sdk.exceptions import ( 10 | CloudClientError, 11 | NotFoundError, 12 | CloudClientUnauthorizedError, 13 | ) 14 | from devcycle_python_sdk.models.event import DevCycleEvent 15 | from devcycle_python_sdk.models.feature import Feature 16 | from devcycle_python_sdk.models.user import DevCycleUser 17 | from devcycle_python_sdk.models.variable import Variable 18 | from devcycle_python_sdk.util.strings import slash_join 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class BucketingAPIClient: 24 | def __init__(self, sdk_key: str, options: DevCycleCloudOptions): 25 | self.sdk_key = sdk_key 26 | self.options = options 27 | self.session = requests.Session() 28 | self.session.headers = { 29 | "Authorization": sdk_key, 30 | "Content-Type": "application/json", 31 | "Accept": "application/json", 32 | } 33 | self.session.max_redirects = 0 34 | 35 | def _url(self, *path_args: str) -> str: 36 | return slash_join(self.options.bucketing_api_uri, "v1", *path_args) 37 | 38 | def request(self, method: str, url: str, **kwargs) -> dict: 39 | retries_remaining = self.options.request_retries + 1 40 | timeout = self.options.request_timeout 41 | 42 | query_params = {} 43 | if self.options.enable_edge_db: 44 | query_params["enableEdgeDB"] = "true" 45 | 46 | attempts = 1 47 | while retries_remaining > 0: 48 | request_error: Optional[Exception] = None 49 | try: 50 | res: requests.Response = self.session.request( 51 | method, url, params=query_params, timeout=timeout, **kwargs 52 | ) 53 | 54 | if res.status_code == 401: 55 | # Not a retryable error 56 | raise CloudClientUnauthorizedError("Invalid SDK Key") 57 | elif res.status_code == 404: 58 | # Not a retryable error 59 | raise NotFoundError(url) 60 | elif 400 <= res.status_code < 500: 61 | # Not a retryable error 62 | raise CloudClientError(f"Bad request: HTTP {res.status_code}") 63 | elif res.status_code >= 500: 64 | # Retryable error 65 | request_error = CloudClientError( 66 | f"Server error: HTTP {res.status_code}" 67 | ) 68 | except requests.exceptions.RequestException as e: 69 | request_error = e 70 | 71 | if not request_error: 72 | break 73 | 74 | logger.debug( 75 | f"DevCycle cloud bucketing request failed (attempt {attempts}): {request_error}" 76 | ) 77 | retries_remaining -= 1 78 | if retries_remaining: 79 | retry_delay = exponential_backoff( 80 | attempts, self.options.retry_delay / 1000.0 81 | ) 82 | time.sleep(retry_delay) 83 | attempts += 1 84 | continue 85 | 86 | raise CloudClientError(message="Retries exceeded", cause=request_error) 87 | 88 | data: dict = res.json() 89 | return data 90 | 91 | def variable(self, key: str, user: DevCycleUser) -> Variable: 92 | data = self.request("POST", self._url("variables", key), json=user.to_json()) 93 | 94 | return Variable( 95 | _id=data.get("_id"), 96 | key=data.get("key", ""), 97 | type=data.get("type", ""), 98 | value=data.get("value"), 99 | ) 100 | 101 | def variables(self, user: DevCycleUser) -> Dict[str, Variable]: 102 | data = self.request("POST", self._url("variables"), json=user.to_json()) 103 | 104 | result: Dict[str, Variable] = {} 105 | for key, value in data.items(): 106 | result[key] = Variable( 107 | _id=str(value.get("_id")), 108 | key=str(value.get("key")), 109 | type=str(value.get("type")), 110 | value=value.get("value"), 111 | isDefaulted=None, 112 | ) 113 | 114 | return result 115 | 116 | def features(self, user: DevCycleUser) -> Dict[str, Feature]: 117 | data = self.request("POST", self._url("features"), json=user.to_json()) 118 | 119 | result: Dict[str, Feature] = {} 120 | for key, value in data.items(): 121 | result[key] = Feature( 122 | _id=value.get("_id"), 123 | key=value.get("key"), 124 | type=value.get("type"), 125 | _variation=value.get("_variation"), 126 | variationKey=value.get("variationKey"), 127 | variationName=value.get("variationName"), 128 | evalReason=value.get("evalReason"), 129 | ) 130 | 131 | return result 132 | 133 | def track(self, user: DevCycleUser, events: List[DevCycleEvent]) -> str: 134 | data = self.request( 135 | "POST", 136 | self._url("track"), 137 | json={ 138 | "user": user.to_json(), 139 | "events": [ 140 | event.to_json(use_bucketing_api_format=True) for event in events 141 | ], 142 | }, 143 | ) 144 | message = data.get("message", "") 145 | return message 146 | -------------------------------------------------------------------------------- /devcycle_python_sdk/api/config_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from http import HTTPStatus 4 | from typing import Optional, Tuple 5 | import email.utils 6 | import requests 7 | 8 | from devcycle_python_sdk.api.backoff import exponential_backoff 9 | from devcycle_python_sdk.options import DevCycleLocalOptions 10 | from devcycle_python_sdk.exceptions import ( 11 | APIClientError, 12 | NotFoundError, 13 | APIClientUnauthorizedError, 14 | ) 15 | from devcycle_python_sdk.util.strings import slash_join 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class ConfigAPIClient: 21 | def __init__(self, sdk_key: str, options: DevCycleLocalOptions): 22 | self.sdk_key = sdk_key 23 | self.options = options 24 | self.session = requests.Session() 25 | self.session.headers = { 26 | "Content-Type": "application/json", 27 | "Accept": "application/json", 28 | } 29 | self.session.max_redirects = 0 30 | self.max_config_retries = 2 31 | self.config_file_url = ( 32 | slash_join( 33 | self.options.config_cdn_uri, "config", "v2", "server", self.sdk_key 34 | ) 35 | + ".json" 36 | ) 37 | 38 | def get_config( 39 | self, config_etag: Optional[str] = None, last_modified: Optional[str] = None 40 | ) -> Tuple[Optional[dict], Optional[str], Optional[str]]: 41 | """ 42 | Get the config from the server. If the config_etag is provided, the server will only return the config if it 43 | has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified 44 | response. 45 | 46 | :param config_etag: The etag of the last config request 47 | :param last_modified: Last modified RFC1123 Timestamp of the stored config 48 | :return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last 49 | request, the config will be None and the etag will be the same as the last request. 50 | """ 51 | retries_remaining = self.max_config_retries 52 | timeout = self.options.config_request_timeout_ms / 1000.0 53 | 54 | url = self.config_file_url 55 | 56 | headers = {} 57 | if config_etag: 58 | headers["If-None-Match"] = config_etag 59 | if last_modified: 60 | headers["If-Modified-Since"] = last_modified 61 | 62 | attempts = 1 63 | while retries_remaining > 0: 64 | request_error: Optional[Exception] = None 65 | try: 66 | res: requests.Response = self.session.request( 67 | "GET", url, params={}, timeout=timeout, headers=headers 68 | ) 69 | 70 | if ( 71 | res.status_code == HTTPStatus.UNAUTHORIZED 72 | or res.status_code == HTTPStatus.FORBIDDEN 73 | ): 74 | # Not a retryable error 75 | raise APIClientUnauthorizedError("Invalid SDK Key") 76 | elif res.status_code == HTTPStatus.NOT_MODIFIED: 77 | # the config hasn't changed since the last request 78 | # don't return anything 79 | return None, config_etag, last_modified 80 | elif res.status_code == HTTPStatus.NOT_FOUND: 81 | # Not a retryable error 82 | raise NotFoundError(url) 83 | elif ( 84 | HTTPStatus.BAD_REQUEST 85 | <= res.status_code 86 | < HTTPStatus.INTERNAL_SERVER_ERROR 87 | ): 88 | # Not a retryable error 89 | raise APIClientError(f"Bad request: HTTP {res.status_code}") 90 | elif res.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: 91 | # Retryable error 92 | request_error = APIClientError( 93 | f"Server error: HTTP {res.status_code}" 94 | ) 95 | else: 96 | pass 97 | except requests.exceptions.RequestException as e: 98 | request_error = e 99 | 100 | if not request_error: 101 | break 102 | 103 | logger.debug( 104 | f"DevCycle config CDN request failed (attempt {attempts}): {request_error}" 105 | ) 106 | retries_remaining -= 1 107 | if retries_remaining: 108 | retry_delay = exponential_backoff( 109 | attempts, self.options.config_retry_delay_ms / 1000.0 110 | ) 111 | time.sleep(retry_delay) 112 | attempts += 1 113 | continue 114 | 115 | raise APIClientError(message="Retries exceeded", cause=request_error) 116 | 117 | new_etag = res.headers.get("ETag", None) 118 | new_lastmodified = res.headers.get("Last-Modified", None) 119 | 120 | if last_modified and new_lastmodified: 121 | stored_lm = email.utils.parsedate_to_datetime(last_modified) 122 | response_lm = email.utils.parsedate_to_datetime(new_lastmodified) 123 | if stored_lm > response_lm: 124 | logger.warning( 125 | "Request returned a last modified header older than the current stored timestamp. not saving config" 126 | ) 127 | return None, None, None 128 | 129 | data: dict = res.json() 130 | return data, new_etag, new_lastmodified 131 | -------------------------------------------------------------------------------- /devcycle_python_sdk/api/event_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from typing import Optional, List 5 | 6 | import requests 7 | 8 | from devcycle_python_sdk.api.backoff import exponential_backoff 9 | from devcycle_python_sdk.options import DevCycleLocalOptions 10 | from devcycle_python_sdk.exceptions import ( 11 | APIClientError, 12 | NotFoundError, 13 | APIClientUnauthorizedError, 14 | ) 15 | from devcycle_python_sdk.models.event import UserEventsBatchRecord 16 | from devcycle_python_sdk.util.strings import slash_join 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class EventAPIClient: 22 | def __init__(self, sdk_key: str, options: DevCycleLocalOptions): 23 | self.options = options 24 | self.session = requests.Session() 25 | self.session.headers = { 26 | "Content-Type": "application/json", 27 | "Accept": "application/json", 28 | "Authorization": sdk_key, 29 | } 30 | self.session.max_redirects = 0 31 | self.max_batch_retries = 0 # we don't retry events batches 32 | self.batch_url = slash_join(self.options.events_api_uri, "v1/events/batch") 33 | 34 | def publish_events(self, batch: List[UserEventsBatchRecord]) -> str: 35 | """ 36 | Attempts to send a batch of events to the server 37 | """ 38 | retries_remaining = self.max_batch_retries + 1 39 | timeout = self.options.event_request_timeout_ms 40 | 41 | payload_json = json.dumps( 42 | { 43 | "batch": [record.to_json() for record in batch], 44 | } 45 | ) 46 | 47 | attempts = 1 48 | while retries_remaining > 0: 49 | request_error: Optional[Exception] = None 50 | try: 51 | res: requests.Response = self.session.request( 52 | "POST", 53 | self.batch_url, 54 | params={}, 55 | timeout=timeout, 56 | data=payload_json, 57 | ) 58 | if res.status_code == 401 or res.status_code == 403: 59 | # Not a retryable error 60 | raise APIClientUnauthorizedError("Invalid SDK Key") 61 | elif res.status_code == 404: 62 | # Not a retryable error - bad URL 63 | raise NotFoundError(self.batch_url) 64 | elif 400 <= res.status_code < 500: 65 | # Not a retryable error 66 | raise APIClientError( 67 | f"Bad request: HTTP {res.status_code} - {res.text}" 68 | ) 69 | elif res.status_code >= 500: 70 | # Retryable error 71 | request_error = APIClientError( 72 | f"Server error: HTTP {res.status_code}" 73 | ) 74 | except requests.exceptions.RequestException as e: 75 | request_error = e 76 | 77 | if not request_error: 78 | break 79 | 80 | logger.debug( 81 | f"DevCycle event batch request failed (attempt {attempts}): {request_error}" 82 | ) 83 | retries_remaining -= 1 84 | if retries_remaining: 85 | retry_delay = exponential_backoff( 86 | attempts, self.options.event_retry_delay_ms / 1000.0 87 | ) 88 | time.sleep(retry_delay) 89 | attempts += 1 90 | continue 91 | 92 | raise APIClientError(message="Retries exceeded", cause=request_error) 93 | 94 | data: dict = res.json() 95 | return data.get("message", None) 96 | -------------------------------------------------------------------------------- /devcycle_python_sdk/bucketing-lib.release.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/bucketing-lib.release.wasm -------------------------------------------------------------------------------- /devcycle_python_sdk/cloud_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import platform 3 | 4 | from typing import Any, Dict 5 | 6 | from devcycle_python_sdk import DevCycleCloudOptions, AbstractDevCycleClient 7 | from devcycle_python_sdk.api.bucketing_client import BucketingAPIClient 8 | from devcycle_python_sdk.exceptions import ( 9 | NotFoundError, 10 | CloudClientUnauthorizedError, 11 | ) 12 | from devcycle_python_sdk.models.user import DevCycleUser 13 | from devcycle_python_sdk.models.event import DevCycleEvent 14 | from devcycle_python_sdk.models.variable import Variable 15 | from devcycle_python_sdk.models.feature import Feature 16 | from devcycle_python_sdk.util.version import sdk_version 17 | from devcycle_python_sdk.open_feature_provider.provider import DevCycleProvider 18 | 19 | from openfeature.provider import AbstractProvider 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class DevCycleCloudClient(AbstractDevCycleClient): 25 | """ 26 | The DevCycle Python SDK that utilizes the DevCycle Bucketing API for feature and variable evaluation 27 | """ 28 | 29 | options: DevCycleCloudOptions 30 | platform: str 31 | platform_version: str 32 | sdk_version: str 33 | 34 | def __init__(self, sdk_key: str, options: DevCycleCloudOptions): 35 | _validate_sdk_key(sdk_key) 36 | 37 | if options is None: 38 | self.options = DevCycleCloudOptions() 39 | else: 40 | self.options = options 41 | 42 | self.platform = "Python" 43 | self.platform_version = platform.python_version() 44 | self.sdk_version = sdk_version() 45 | self.sdk_type = "server" 46 | self.bucketing_api = BucketingAPIClient(sdk_key, self.options) 47 | self._openfeature_provider = DevCycleProvider(self) 48 | 49 | def get_sdk_platform(self) -> str: 50 | return "Cloud" 51 | 52 | def get_openfeature_provider(self) -> AbstractProvider: 53 | return self._openfeature_provider 54 | 55 | def _add_platform_data_to_user(self, user: DevCycleUser) -> DevCycleUser: 56 | user.platform = self.platform 57 | user.platformVersion = self.platform_version 58 | user.sdkVersion = self.sdk_version 59 | user.sdkType = self.sdk_type 60 | return user 61 | 62 | def is_initialized(self) -> bool: 63 | return True 64 | 65 | def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any: 66 | """ 67 | Evaluates a variable for a user and returns the value. If the user is not bucketed into the variable, the default value will be returned 68 | 69 | :param user: The user to evaluate the variable for 70 | """ 71 | return self.variable(user, key, default_value).value 72 | 73 | def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable: 74 | """ 75 | Evaluates a variable for a user. 76 | 77 | :param user: The user to evaluate the variable for 78 | :param key: The key of the variable to evaluate 79 | :param default_value: The default value to return if the user is not bucketed into the variable 80 | """ 81 | _validate_user(user) 82 | user = self._add_platform_data_to_user(user) 83 | 84 | if not key: 85 | raise ValueError("Missing parameter: key") 86 | 87 | if default_value is None: 88 | raise ValueError("Missing parameter: defaultValue") 89 | 90 | try: 91 | variable = self.bucketing_api.variable(key, user) 92 | except CloudClientUnauthorizedError as e: 93 | logger.warning("DevCycle: SDK key is invalid, unable to make cloud request") 94 | raise e 95 | except NotFoundError: 96 | logger.warning(f"DevCycle: Variable not found: {key}") 97 | return Variable.create_default_variable( 98 | key=key, default_value=default_value 99 | ) 100 | except Exception as e: 101 | logger.error(f"DevCycle: Error evaluating variable: {e}") 102 | return Variable.create_default_variable( 103 | key=key, default_value=default_value 104 | ) 105 | 106 | variable.defaultValue = default_value 107 | 108 | # Allow default value to be a subclass of the same type as the variable 109 | if not isinstance(default_value, type(variable.value)): 110 | logger.warning( 111 | f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}", 112 | ) 113 | return Variable.create_default_variable( 114 | key=key, default_value=default_value 115 | ) 116 | 117 | return variable 118 | 119 | def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]: 120 | """ 121 | Returns all segmented and bucketed variables for a user. This method will return an empty map if the user is not bucketed into any variables 122 | 123 | :param user: The user to retrieve features for 124 | """ 125 | _validate_user(user) 126 | user = self._add_platform_data_to_user(user) 127 | 128 | variable_map: Dict[str, Variable] = {} 129 | try: 130 | variable_map = self.bucketing_api.variables(user) 131 | except CloudClientUnauthorizedError as e: 132 | logger.warning("DevCycle: SDK key is invalid, unable to make cloud request") 133 | raise e 134 | except Exception as e: 135 | logger.error(f"DevCycle: Error retrieving all features for a user: {e}") 136 | 137 | return variable_map 138 | 139 | def all_features(self, user: DevCycleUser) -> Dict[str, Feature]: 140 | """ 141 | Returns all segmented and bucketed features for a user. This method will return an empty map if the user is not bucketed into any features 142 | 143 | :param user: The user to retrieve features for 144 | """ 145 | _validate_user(user) 146 | user = self._add_platform_data_to_user(user) 147 | 148 | feature_map: Dict[str, Feature] = {} 149 | try: 150 | feature_map = self.bucketing_api.features(user) 151 | except CloudClientUnauthorizedError as e: 152 | logger.warning("DevCycle: SDK key is invalid, unable to make cloud request") 153 | raise e 154 | except Exception as e: 155 | logger.error(f"DevCycle: Error retrieving all features for a user: {e}") 156 | 157 | return feature_map 158 | 159 | def track(self, user: DevCycleUser, user_event: DevCycleEvent) -> None: 160 | """ 161 | Tracks a custom event for a user. 162 | 163 | :param user: The user to track the event for 164 | :param user_event: The event to track 165 | """ 166 | 167 | if user_event is None or not user_event.type: 168 | raise ValueError("Invalid Event") 169 | 170 | _validate_user(user) 171 | user = self._add_platform_data_to_user(user) 172 | 173 | if user_event is None or not user_event.type: 174 | raise ValueError("Invalid Event") 175 | 176 | events = [user_event] 177 | try: 178 | self.bucketing_api.track(user, events) 179 | except CloudClientUnauthorizedError as e: 180 | logger.warning("DevCycle: SDK key is invalid, unable to make cloud request") 181 | raise e 182 | except Exception as e: 183 | logger.error(f"DevCycle: Error tracking event: {e}") 184 | 185 | def close(self) -> None: 186 | """ 187 | Closes the client and releases any resources held by it. 188 | """ 189 | # Cloud client doesn't need to release any resources 190 | logger.debug("DevCycle: Cloud client closed") 191 | 192 | 193 | def _validate_sdk_key(sdk_key: str) -> None: 194 | if sdk_key is None or len(sdk_key) == 0: 195 | raise ValueError("Missing SDK key! Call initialize with a valid SDK key") 196 | 197 | if not sdk_key.startswith("server") and not sdk_key.startswith("dvc_server"): 198 | raise ValueError( 199 | "Invalid SDK key provided. Please call initialize with a valid server SDK key" 200 | ) 201 | 202 | 203 | def _validate_user(user: DevCycleUser) -> None: 204 | if user is None: 205 | raise ValueError("User cannot be None") 206 | 207 | if user.user_id is None or len(user.user_id) == 0: 208 | raise ValueError("userId cannot be empty") 209 | -------------------------------------------------------------------------------- /devcycle_python_sdk/devcycle_client.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import abstractmethod 3 | 4 | from devcycle_python_sdk.models.user import DevCycleUser 5 | from devcycle_python_sdk.models.variable import Variable 6 | 7 | from openfeature.provider import AbstractProvider 8 | 9 | 10 | class AbstractDevCycleClient: 11 | """ 12 | A common interface for all DevCycle Clients 13 | """ 14 | 15 | @abstractmethod 16 | def is_initialized(self) -> bool: 17 | pass 18 | 19 | @abstractmethod 20 | def variable( 21 | self, user: DevCycleUser, key: str, default_value: typing.Any 22 | ) -> Variable: 23 | pass 24 | 25 | @abstractmethod 26 | def variable_value( 27 | self, user: DevCycleUser, key: str, default_value: typing.Any 28 | ) -> typing.Any: 29 | pass 30 | 31 | @abstractmethod 32 | def get_openfeature_provider(self) -> AbstractProvider: 33 | """ 34 | Returns the OpenFeature provider for this client 35 | """ 36 | pass 37 | 38 | @abstractmethod 39 | def get_sdk_platform(self) -> str: 40 | pass 41 | 42 | @abstractmethod 43 | def close(self) -> None: 44 | """ 45 | Closes the client and releases any resources held by it. 46 | """ 47 | pass 48 | -------------------------------------------------------------------------------- /devcycle_python_sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class APIClientError(Exception): 5 | def __init__(self, message: str, cause: Optional[Exception] = None): 6 | self.message = message 7 | self.__cause__ = cause 8 | 9 | def __str__(self): 10 | return f"APIClientError: {self.message}" 11 | 12 | 13 | class APIClientUnauthorizedError(Exception): 14 | def __init__(self, message: str): 15 | self.message = message 16 | super().__init__(message) 17 | 18 | 19 | class CloudClientError(APIClientError): 20 | def __init__(self, message: str, cause: Optional[Exception] = None): 21 | super().__init__(message, cause) 22 | 23 | def __str__(self): 24 | return f"CloudClientException: {self.message}" 25 | 26 | 27 | class CloudClientUnauthorizedError(APIClientUnauthorizedError): 28 | def __init__(self, message: str): 29 | super().__init__(message) 30 | 31 | 32 | class NotFoundError(Exception): 33 | def __init__(self, key: str): 34 | self.key = key 35 | 36 | 37 | class VariableTypeMismatchError(Exception): 38 | def __init__(self, message: str): 39 | super().__init__(message) 40 | 41 | 42 | class MalformedConfigError(Exception): 43 | pass 44 | -------------------------------------------------------------------------------- /devcycle_python_sdk/managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/managers/__init__.py -------------------------------------------------------------------------------- /devcycle_python_sdk/managers/config_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import threading 4 | import time 5 | from datetime import datetime 6 | from typing import Optional 7 | 8 | import ld_eventsource.actions 9 | 10 | from devcycle_python_sdk.api.config_client import ConfigAPIClient 11 | from devcycle_python_sdk.api.local_bucketing import LocalBucketing 12 | from devcycle_python_sdk.exceptions import ( 13 | CloudClientUnauthorizedError, 14 | CloudClientError, 15 | ) 16 | from wsgiref.handlers import format_date_time 17 | from devcycle_python_sdk.options import DevCycleLocalOptions 18 | from devcycle_python_sdk.managers.sse_manager import SSEManager 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class EnvironmentConfigManager(threading.Thread): 24 | def __init__( 25 | self, 26 | sdk_key: str, 27 | options: DevCycleLocalOptions, 28 | local_bucketing: LocalBucketing, 29 | ): 30 | super().__init__() 31 | 32 | self._sdk_key = sdk_key 33 | self._options = options 34 | self._local_bucketing = local_bucketing 35 | self._sse_manager: Optional[SSEManager] = None 36 | self._sse_polling_interval = 1000 * 60 * 15 * 60 37 | self._sse_connected = False 38 | self._config: Optional[dict] = None 39 | self._config_etag: Optional[str] = None 40 | self._config_lastmodified: Optional[str] = None 41 | 42 | self._config_api_client = ConfigAPIClient(self._sdk_key, self._options) 43 | 44 | self._polling_enabled = True 45 | self.daemon = True 46 | self.start() 47 | 48 | def is_initialized(self) -> bool: 49 | return self._config is not None 50 | 51 | def _get_config(self, last_modified: Optional[float] = None): 52 | try: 53 | lm_header = self._config_lastmodified 54 | if last_modified is not None: 55 | lm_timestamp = datetime.fromtimestamp(last_modified) 56 | lm_header = format_date_time(time.mktime(lm_timestamp.timetuple())) 57 | 58 | new_config, new_etag, new_lastmodified = self._config_api_client.get_config( 59 | config_etag=self._config_etag, last_modified=lm_header 60 | ) 61 | 62 | # Abort early if the last modified is before the sent one. 63 | if new_config is None and new_etag is None and new_lastmodified is None: 64 | return 65 | if new_config is None and new_etag == self._config_etag: 66 | # api not returning data and the etag is the same 67 | # no change to the config since last request 68 | return 69 | elif new_config is None: 70 | logger.warning( 71 | "DevCycle: Config CDN fetch returned no data with a different etag" 72 | ) 73 | return 74 | 75 | trigger_on_client_initialized = self._config is None 76 | 77 | self._config = new_config 78 | self._config_etag = new_etag 79 | self._config_lastmodified = new_lastmodified 80 | 81 | json_config = json.dumps(self._config) 82 | self._local_bucketing.store_config(json_config) 83 | if not self._options.disable_realtime_updates: 84 | if self._sse_manager is None: 85 | self._sse_manager = SSEManager( 86 | self.sse_state, 87 | self.sse_error, 88 | self.sse_message, 89 | ) 90 | self._sse_manager.update(self._config) 91 | 92 | if ( 93 | trigger_on_client_initialized 94 | and self._options.on_client_initialized is not None 95 | ): 96 | try: 97 | self._options.on_client_initialized() 98 | except Exception as e: 99 | # consume any error 100 | logger.warning( 101 | f"DevCycle: Error received from on_client_initialized callback: {str(e)}" 102 | ) 103 | except CloudClientError as e: 104 | logger.warning(f"DevCycle: Config fetch failed. Status: {str(e)}") 105 | except CloudClientUnauthorizedError: 106 | logger.error( 107 | "DevCycle: Unauthorized to get config. Aborting config polling." 108 | ) 109 | self._polling_enabled = False 110 | 111 | def run(self): 112 | while self._polling_enabled: 113 | try: 114 | self._get_config() 115 | except Exception as e: 116 | if self._polling_enabled: 117 | # Only log a warning if we're still polling 118 | logger.warning( 119 | f"DevCycle: Error polling for config changes: {str(e)}" 120 | ) 121 | if self._sse_connected: 122 | time.sleep(self._sse_polling_interval / 1000.0) 123 | else: 124 | time.sleep(self._options.config_polling_interval_ms / 1000.0) 125 | 126 | def sse_message(self, message: ld_eventsource.actions.Event): 127 | if self._sse_connected is False: 128 | self._sse_connected = True 129 | logger.info("DevCycle: Connected to SSE stream") 130 | logger.info(f"DevCycle: Received message: {message.data}") 131 | sse_message = json.loads(message.data) 132 | dvc_data = json.loads(sse_message.get("data")) 133 | if ( 134 | dvc_data.get("type") == "refetchConfig" 135 | or dvc_data.get("type") == "" 136 | or dvc_data.get("type") is None 137 | ): 138 | logger.info("DevCycle: Received refetchConfig message - updating config") 139 | self._get_config(dvc_data["lastModified"] / 1000.0) 140 | 141 | def sse_error(self, error: ld_eventsource.actions.Fault): 142 | logger.debug(f"DevCycle: Received SSE error: {error}") 143 | 144 | def sse_state(self, state: ld_eventsource.actions.Start): 145 | self._sse_connected = True 146 | logger.info("DevCycle: Connected to SSE stream") 147 | 148 | def close(self): 149 | self._polling_enabled = False 150 | -------------------------------------------------------------------------------- /devcycle_python_sdk/managers/event_queue_manager.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import logging 3 | import json 4 | from typing import Optional 5 | 6 | from devcycle_python_sdk.options import DevCycleLocalOptions 7 | from devcycle_python_sdk.api.local_bucketing import LocalBucketing 8 | from devcycle_python_sdk.api.event_client import EventAPIClient 9 | from devcycle_python_sdk.exceptions import ( 10 | APIClientError, 11 | APIClientUnauthorizedError, 12 | NotFoundError, 13 | ) 14 | from devcycle_python_sdk.models.event import ( 15 | FlushPayload, 16 | DevCycleEvent, 17 | EventType, 18 | ) 19 | from devcycle_python_sdk.models.user import DevCycleUser 20 | from devcycle_python_sdk.models.bucketed_config import BucketedConfig 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class QueueFullError(Exception): 27 | pass 28 | 29 | 30 | class EventQueueManager(threading.Thread): 31 | def __init__( 32 | self, 33 | sdk_key: str, 34 | client_uuid: str, 35 | options: DevCycleLocalOptions, 36 | local_bucketing: LocalBucketing, 37 | ): 38 | super().__init__() 39 | 40 | if sdk_key is None or sdk_key == "": 41 | raise ValueError("DevCycle is not yet initialized to publish events.") 42 | 43 | self._sdk_key = sdk_key 44 | self._options = options 45 | self._local_bucketing = local_bucketing 46 | self._event_api_client = EventAPIClient(self._sdk_key, self._options) 47 | self._flush_lock = threading.Lock() 48 | self._exit = threading.Event() 49 | self._exited = threading.Event() 50 | 51 | # Setup the event queue inside the WASM module 52 | event_options_json = json.dumps(self._options.event_queue_options()) 53 | self._local_bucketing.init_event_queue(client_uuid, event_options_json) 54 | 55 | # Only start event processing thread if event logging is enabled 56 | if not ( 57 | self._options.disable_custom_event_logging 58 | and self._options.disable_automatic_event_logging 59 | ): 60 | self.daemon = True 61 | self.start() 62 | else: 63 | self._stop_running() 64 | self._mark_exited() 65 | 66 | def _should_run(self) -> bool: 67 | # Returns true if the thread should continue running, false otherwise 68 | return not self._exit.is_set() 69 | 70 | def _stop_running(self) -> None: 71 | # Indicate to the thread that it should stop running, interrupting any sleep 72 | self._exit.set() 73 | 74 | def _sleep(self) -> bool: 75 | # Returns true if the sleep was interrupted, false otherwise 76 | return self._exit.wait(self._options.event_flush_interval_ms / 1000.0) 77 | 78 | def _mark_exited(self) -> None: 79 | # Indicate to the thread calling close that the thread has exited 80 | self._exited.set() 81 | 82 | def _wait_for_exit(self, timeout_seconds: float) -> bool: 83 | # Wait up to timeout_seconds for the thread to exit 84 | # This works whether the thread was actually started or not 85 | return self._exited.wait(timeout_seconds) 86 | 87 | def _flush_events(self) -> int: 88 | if self._flush_lock.locked(): 89 | return 0 90 | 91 | with self._flush_lock: 92 | try: 93 | payloads = self._local_bucketing.flush_event_queue() 94 | except Exception as e: 95 | logger.error(f"DevCycle: Error flushing event payloads: {str(e)}") 96 | 97 | event_count = 0 98 | if payloads: 99 | logger.debug(f"DevCycle: Flush {len(payloads)} event payloads") 100 | for payload in payloads: 101 | event_count += payload.eventCount 102 | self._publish_event_payload(payload) 103 | logger.debug( 104 | f"DevCycle: Flush {event_count} events, for {len(payloads)} users" 105 | ) 106 | return event_count 107 | 108 | def _publish_event_payload(self, payload: FlushPayload) -> None: 109 | if payload and payload.records: 110 | try: 111 | self._event_api_client.publish_events(payload.records) 112 | self._local_bucketing.on_event_payload_success(payload.payloadId) 113 | except APIClientUnauthorizedError: 114 | logger.error( 115 | "DevCycle: Unauthorized to publish events, please check your SDK key" 116 | ) 117 | # stop the thread 118 | self._stop_running() 119 | self._local_bucketing.on_event_payload_failure(payload.payloadId, False) 120 | except NotFoundError as e: 121 | logger.error( 122 | f"DevCycle: Unable to reach the DevCycle Events API service: {str(e)}" 123 | ) 124 | self._stop_running() 125 | self._local_bucketing.on_event_payload_failure(payload.payloadId, False) 126 | except APIClientError as e: 127 | logger.warning( 128 | f"DevCycle: Error publishing events to DevCycle Events API service: {str(e)}" 129 | ) 130 | self._local_bucketing.on_event_payload_failure(payload.payloadId, True) 131 | 132 | def is_event_logging_disabled(self, event_type: str) -> bool: 133 | if event_type in [ 134 | EventType.VariableDefaulted, 135 | EventType.VariableEvaluated, 136 | EventType.AggVariableDefaulted, 137 | EventType.AggVariableEvaluated, 138 | ]: 139 | return self._options.disable_automatic_event_logging 140 | else: 141 | return self._options.disable_custom_event_logging 142 | 143 | def run(self): 144 | while self._should_run(): 145 | try: 146 | self._flush_events() 147 | except Exception as e: 148 | logger.warning(f"DevCycle: flushing events: {str(e)}") 149 | 150 | self._sleep() 151 | 152 | self._mark_exited() 153 | 154 | def close(self): 155 | self._stop_running() 156 | 157 | # Wait up to 1s for the thread to exit. 158 | # Because the sleeping between batches is interruptible, this is only 159 | # providing time for an in-flight batch to finish. 160 | if not self._wait_for_exit(1.0): 161 | logger.error( 162 | "DevCycle: Timed out waiting for event flushing thread to stop" 163 | ) 164 | 165 | try: 166 | self._flush_events() 167 | except Exception as e: 168 | logger.warning(f"DevCycle: flushing events when closing client: {str(e)}") 169 | 170 | def queue_event(self, user: DevCycleUser, event: DevCycleEvent) -> None: 171 | if user is None: 172 | raise ValueError("user cannot be None") 173 | 174 | if event is None: 175 | raise ValueError("event cannot be None") 176 | 177 | if not event.type: 178 | raise ValueError("event type not set") 179 | 180 | try: 181 | self._check_queue_status() 182 | except QueueFullError: 183 | logger.warning("DevCycle: Event queue is full, dropping user event") 184 | return 185 | 186 | user_json = json.dumps(user.to_json()) 187 | event_json = json.dumps(event.to_json()) 188 | self._local_bucketing.queue_event(user_json, event_json) 189 | 190 | def queue_aggregate_event( 191 | self, event: DevCycleEvent, bucketed_config: Optional[BucketedConfig] 192 | ) -> None: 193 | if event is None: 194 | raise ValueError("event cannot be None") 195 | 196 | if not event.type: 197 | raise ValueError("event type not set") 198 | 199 | if not event.target: 200 | raise ValueError("event target not set") 201 | 202 | try: 203 | self._check_queue_status() 204 | except QueueFullError: 205 | logger.warning("DevCycle: Event queue is full, dropping aggregate event") 206 | return 207 | 208 | event_json = json.dumps(event.to_json()) 209 | if bucketed_config and bucketed_config.variable_variation_map: 210 | variation_map_json = json.dumps(bucketed_config.variable_variation_map) 211 | else: 212 | variation_map_json = "{}" 213 | self._local_bucketing.queue_aggregate_event(event_json, variation_map_json) 214 | 215 | def _check_queue_status(self) -> None: 216 | if self._flush_needed(): 217 | self._flush_events() 218 | 219 | if self._queue_full(): 220 | raise QueueFullError() 221 | 222 | def _flush_needed(self) -> bool: 223 | queue_size = self._local_bucketing.get_event_queue_size() 224 | return queue_size >= self._options.flush_event_queue_size 225 | 226 | def _queue_full(self) -> bool: 227 | queue_size = self._local_bucketing.get_event_queue_size() 228 | return queue_size >= self._options.max_event_queue_size 229 | -------------------------------------------------------------------------------- /devcycle_python_sdk/managers/sse_manager.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import ld_eventsource 4 | import ld_eventsource.actions 5 | import logging 6 | import ld_eventsource.config 7 | from typing import Callable 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SSEManager: 13 | def __init__( 14 | self, 15 | handle_state: Callable[[ld_eventsource.actions.Start], None], 16 | handle_error: Callable[[ld_eventsource.actions.Fault], None], 17 | handle_message: Callable[[ld_eventsource.actions.Event], None], 18 | ): 19 | self.client: ld_eventsource.SSEClient = None 20 | self.url = "" 21 | self.handle_state = handle_state 22 | self.handle_error = handle_error 23 | self.handle_message = handle_message 24 | 25 | self.read_thread = threading.Thread( 26 | target=self.read_events, 27 | args=(self.handle_state, self.handle_error, self.handle_message), 28 | ) 29 | 30 | def read_events( 31 | self, 32 | handle_state: Callable[[ld_eventsource.actions.Start], None], 33 | handle_error: Callable[[ld_eventsource.actions.Fault], None], 34 | handle_message: Callable[[ld_eventsource.actions.Event], None], 35 | ): 36 | self.client.start() 37 | try: 38 | for event in self.client.all: 39 | if isinstance(event, ld_eventsource.actions.Start): 40 | handle_state(event) 41 | elif isinstance(event, ld_eventsource.actions.Fault): 42 | handle_error(event) 43 | elif isinstance(event, ld_eventsource.actions.Event): 44 | handle_message(event) 45 | except Exception as e: 46 | logger.exception(f"DevCycle: failed to read SSE message: {e}") 47 | 48 | def update(self, config: dict): 49 | if self.use_new_config(config["sse"]): 50 | self.url = config["sse"]["hostname"] + config["sse"]["path"] 51 | if self.client is not None: 52 | self.client.close() 53 | if self.read_thread.is_alive(): 54 | self.read_thread.join() 55 | self.client = ld_eventsource.SSEClient( 56 | connect=ld_eventsource.config.ConnectStrategy.http(self.url), 57 | error_strategy=ld_eventsource.config.ErrorStrategy.CONTINUE, 58 | ) 59 | self.read_thread = threading.Thread( 60 | target=self.read_events, 61 | args=(self.handle_state, self.handle_error, self.handle_message), 62 | ) 63 | self.read_thread.start() 64 | 65 | def use_new_config(self, config: dict) -> bool: 66 | new_url = config["hostname"] + config["path"] 67 | if self.url == "" or self.url is None and new_url != "": 68 | return True 69 | return self.url != new_url 70 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/models/__init__.py -------------------------------------------------------------------------------- /devcycle_python_sdk/models/bucketed_config.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass 3 | from typing import Dict, List 4 | from typing import Optional 5 | 6 | from .user import DevCycleUser 7 | from .variable import Variable 8 | from .feature import Feature 9 | 10 | 11 | @dataclass 12 | class EdgeDBSettings: 13 | enabled: bool 14 | 15 | @classmethod 16 | def from_json(cls, data: dict) -> "EdgeDBSettings": 17 | return cls( 18 | enabled=data.get("enabled", False), 19 | ) 20 | 21 | 22 | @dataclass 23 | class OptInColors: 24 | primary: str 25 | secondary: str 26 | 27 | @classmethod 28 | def from_json(cls, data: dict) -> "OptInColors": 29 | return cls( 30 | primary=data.get("primary", ""), 31 | secondary=data.get("secondary", ""), 32 | ) 33 | 34 | 35 | @dataclass 36 | class OptInSettings: 37 | enabled: bool 38 | title: str 39 | description: str 40 | image_url: str 41 | colors: OptInColors 42 | 43 | @classmethod 44 | def from_json(cls, data: dict) -> "OptInSettings": 45 | return cls( 46 | enabled=data.get("enabled", False), 47 | title=data.get("title", ""), 48 | description=data.get("description", ""), 49 | image_url=data.get("imageURL", ""), 50 | colors=OptInColors.from_json(data.get("colors", "")), 51 | ) 52 | 53 | 54 | @dataclass 55 | class ProjectSettings: 56 | edge_db: Optional[EdgeDBSettings] 57 | opt_in: Optional[OptInSettings] 58 | disable_passthrough_rollouts: Optional[bool] 59 | 60 | @classmethod 61 | def from_json(cls, data: dict) -> "ProjectSettings": 62 | return cls( 63 | edge_db=( 64 | EdgeDBSettings.from_json(data["edgeDB"]) if "edgeDB" in data else None 65 | ), 66 | opt_in=OptInSettings.from_json(data["optIn"]) if "optIn" in data else None, 67 | disable_passthrough_rollouts=data.get("disablePassthroughRollouts", False), 68 | ) 69 | 70 | 71 | @dataclass 72 | class Project: 73 | id: str 74 | key: str 75 | a0_organization: str 76 | settings: Optional[ProjectSettings] 77 | 78 | @classmethod 79 | def from_json(cls, data: dict) -> "Project": 80 | return cls( 81 | id=data["_id"], 82 | key=data["key"], 83 | a0_organization=data["a0_organization"], 84 | settings=( 85 | ProjectSettings.from_json(data["settings"]) 86 | if "settings" in data 87 | else None 88 | ), 89 | ) 90 | 91 | 92 | @dataclass 93 | class Environment: 94 | id: str 95 | key: str 96 | 97 | @classmethod 98 | def from_json(cls, data: dict) -> "Environment": 99 | return cls( 100 | id=data["_id"], 101 | key=data["key"], 102 | ) 103 | 104 | 105 | @dataclass 106 | class FeatureVariation: 107 | feature: str 108 | variation: str 109 | 110 | @classmethod 111 | def from_json(cls, data: dict) -> "FeatureVariation": 112 | return cls( 113 | feature=data["_feature"], 114 | variation=data["_variation"], 115 | ) 116 | 117 | 118 | @dataclass(order=False) 119 | class BucketedConfig: 120 | project: Project 121 | environment: Environment 122 | features: Dict[str, Feature] 123 | feature_variation_map: Dict[str, str] 124 | variable_variation_map: Dict[str, FeatureVariation] 125 | variables: Dict[str, Variable] 126 | known_variable_keys: List[float] 127 | 128 | user: Optional[DevCycleUser] = None 129 | 130 | def to_json(self): 131 | return { 132 | key: getattr(self, key) 133 | for key in self.__dataclass_fields__ 134 | if getattr(self, key) is not None 135 | } 136 | 137 | @classmethod 138 | def from_json(cls, data: dict) -> "BucketedConfig": 139 | return cls( 140 | project=Project.from_json(data["project"]), 141 | environment=Environment.from_json(data["environment"]), 142 | features={ 143 | k: Feature.from_json(v) for k, v in data.get("features", {}).items() 144 | }, 145 | feature_variation_map=data.get("featureVariationMap", {}), 146 | variable_variation_map={ 147 | k: FeatureVariation.from_json(v) 148 | for k, v in data.get("variableVariationMap", {}).items() 149 | }, 150 | variables={ 151 | k: Variable.from_json(v) for k, v in data.get("variables", {}).items() 152 | }, 153 | known_variable_keys=data.get("knownVariableKeys", []), 154 | ) 155 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/error_response.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass, field 3 | from typing import Any, Dict, Optional 4 | 5 | 6 | @dataclass(order=False) 7 | class ErrorResponse: 8 | message: str 9 | statusCode: Optional[int] = 0 10 | data: Dict[str, Any] = field(default_factory=dict) 11 | 12 | def to_json(self) -> dict: 13 | return { 14 | key: getattr(self, key) 15 | for key in self.__dataclass_fields__ 16 | if getattr(self, key) is not None 17 | } 18 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/event.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass, field 3 | from typing import Dict, Optional, Any, List 4 | from datetime import datetime, timezone 5 | 6 | from .user import DevCycleUser 7 | 8 | 9 | class EventType: 10 | VariableEvaluated = "variableEvaluated" 11 | AggVariableEvaluated = "aggVariableEvaluated" 12 | VariableDefaulted = "variableDefaulted" 13 | AggVariableDefaulted = "aggVariableDefaulted" 14 | CustomEvent = "customEvent" 15 | 16 | 17 | @dataclass(order=False) 18 | class DevCycleEvent: 19 | type: Optional[str] = None 20 | target: Optional[str] = None 21 | date: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) 22 | value: Optional[int] = None 23 | metaData: Optional[Dict[str, str]] = None 24 | 25 | def to_json(self, use_bucketing_api_format: bool = False) -> Dict[str, Any]: 26 | json_dict = { 27 | key: getattr(self, key) 28 | for key in self.__dataclass_fields__ 29 | if getattr(self, key) is not None and key != "date" 30 | } 31 | if self.date: 32 | if use_bucketing_api_format: 33 | # convert to timestamp in milliseconds as required by the bucketing API 34 | json_dict["date"] = int(self.date.timestamp() * 1000) 35 | else: 36 | # convert to UTC and format as ISO string 37 | json_dict["date"] = self.date.astimezone(tz=timezone.utc).isoformat() 38 | return json_dict 39 | 40 | 41 | @dataclass(order=False) 42 | class RequestEvent: 43 | """ 44 | An event generated by local bucketing event that can be sent to the Events API 45 | Not interfaced by developers, purely for internal use 46 | """ 47 | 48 | type: str 49 | user_id: str 50 | # will be a timestamp in iso format 51 | clientDate: str 52 | # will be a timestamp in iso format 53 | date: str 54 | target: Optional[str] = None 55 | customType: Optional[str] = None 56 | value: Optional[float] = 0 57 | featureVars: Dict[str, str] = field(default_factory=dict) 58 | metaData: Optional[Dict[str, str]] = None 59 | 60 | def to_json(self): 61 | json_obj = {} 62 | for key in self.__dataclass_fields__: 63 | json_obj[key] = getattr(self, key) 64 | 65 | return json_obj 66 | 67 | @classmethod 68 | def from_json(cls, data: dict) -> "RequestEvent": 69 | return cls( 70 | type=data["type"], 71 | user_id=data["user_id"], 72 | clientDate=data["clientDate"], 73 | date=data["date"], 74 | target=data.get("target"), 75 | customType=data.get("customType"), 76 | value=data.get("value", 0), 77 | featureVars=data.get("featureVars", {}), 78 | metaData=data.get("metaData"), 79 | ) 80 | 81 | 82 | @dataclass() 83 | class UserEventsBatchRecord: 84 | """ 85 | A collection of events generated by local bucketing library for a single user 86 | """ 87 | 88 | user: DevCycleUser 89 | events: List[RequestEvent] 90 | 91 | def to_json(self) -> Dict[str, Any]: 92 | return { 93 | "user": self.user.to_json(), 94 | "events": [event.to_json() for event in self.events], 95 | } 96 | 97 | @classmethod 98 | def from_json(cls, data: dict) -> "UserEventsBatchRecord": 99 | return cls( 100 | user=DevCycleUser.from_json(data["user"]), 101 | events=[RequestEvent.from_json(element) for element in data["events"]], 102 | ) 103 | 104 | 105 | @dataclass() 106 | class FlushPayload: 107 | """ 108 | A collection of events exported by the local bucketing library that can be sent to the Events API as a batch 109 | """ 110 | 111 | payloadId: str 112 | records: List[UserEventsBatchRecord] 113 | eventCount: int 114 | 115 | @classmethod 116 | def from_json(cls, data: dict) -> "FlushPayload": 117 | return cls( 118 | payloadId=data["payloadId"], 119 | eventCount=data["eventCount"], 120 | records=[ 121 | UserEventsBatchRecord.from_json(element) for element in data["records"] 122 | ], 123 | ) 124 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/feature.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | 6 | @dataclass(order=False) 7 | class Feature: 8 | _id: str 9 | key: str 10 | type: str 11 | _variation: str 12 | variationName: Optional[str] = None 13 | variationKey: Optional[str] = None 14 | evalReason: Optional[str] = None 15 | 16 | def to_json(self): 17 | return { 18 | key: getattr(self, key) 19 | for key in self.__dataclass_fields__ 20 | if getattr(self, key) is not None 21 | } 22 | 23 | @classmethod 24 | def from_json(cls, data: dict) -> "Feature": 25 | return cls( 26 | _id=data["_id"], 27 | key=data["key"], 28 | type=data["type"], 29 | _variation=data["_variation"], 30 | variationName=data["variationName"], 31 | variationKey=data["variationKey"], 32 | evalReason=data.get("evalReason"), 33 | ) 34 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/platform_data.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | import platform 3 | import socket 4 | from typing import Optional 5 | from dataclasses import dataclass 6 | from devcycle_python_sdk.util.version import sdk_version 7 | 8 | 9 | @dataclass(order=False) 10 | class PlatformData: 11 | sdkType: str 12 | sdkVersion: str 13 | platformVersion: str 14 | deviceModel: str 15 | platform: str 16 | hostname: str 17 | sdkPlatform: Optional[str] = None 18 | 19 | def to_json(self): 20 | return { 21 | key: getattr(self, key) 22 | for key in self.__dataclass_fields__ 23 | if getattr(self, key) is not None 24 | } 25 | 26 | 27 | def default_platform_data() -> PlatformData: 28 | return PlatformData( 29 | sdkType="server", 30 | sdkVersion=sdk_version(), 31 | platformVersion=platform.python_version(), 32 | deviceModel=platform.platform(), 33 | platform="Python", 34 | hostname=socket.gethostname(), 35 | ) 36 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/user.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass, field 3 | from datetime import datetime, timezone 4 | from typing import Dict, Optional, Any 5 | from openfeature.evaluation_context import EvaluationContext 6 | from openfeature.exception import TargetingKeyMissingError, InvalidContextError 7 | 8 | 9 | @dataclass(order=False) 10 | class DevCycleUser: 11 | user_id: str 12 | email: Optional[str] = None 13 | name: Optional[str] = None 14 | language: Optional[str] = None 15 | country: Optional[str] = None 16 | appVersion: Optional[str] = None 17 | appBuild: Optional[str] = None 18 | customData: Optional[Dict[str, Any]] = None 19 | createdDate: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) 20 | privateCustomData: Optional[Dict[str, Any]] = None 21 | lastSeenDate: Optional[datetime] = None 22 | platform: Optional[str] = None 23 | platformVersion: Optional[str] = None 24 | deviceModel: Optional[str] = None 25 | sdkType: Optional[str] = None 26 | sdkVersion: Optional[str] = None 27 | sdkPlatform: Optional[str] = None 28 | 29 | def to_json(self): 30 | json_dict = { 31 | key: getattr(self, key) 32 | for key in self.__dataclass_fields__ 33 | if getattr(self, key) is not None 34 | and key not in ["createdDate", "lastSeenDate"] 35 | } 36 | 37 | if self.createdDate: 38 | json_dict["createdDate"] = self.createdDate.astimezone( 39 | tz=timezone.utc 40 | ).isoformat() 41 | if self.lastSeenDate: 42 | json_dict["lastSeenDate"] = self.lastSeenDate.astimezone( 43 | tz=timezone.utc 44 | ).isoformat() 45 | return json_dict 46 | 47 | @classmethod 48 | def from_json(cls, data: dict) -> "DevCycleUser": 49 | if "createdDate" in data: 50 | created_date = datetime.fromisoformat( 51 | data["createdDate"].replace("Z", "+00:00") 52 | ) 53 | else: 54 | created_date = datetime.now(timezone.utc) 55 | 56 | last_seen_date = None 57 | if "lastSeenDate" in data: 58 | last_seen_date = datetime.fromisoformat( 59 | data["lastSeenDate"].replace("Z", "+00:00") 60 | ) 61 | 62 | return cls( 63 | user_id=data["user_id"], 64 | email=data.get("email"), 65 | name=data.get("name"), 66 | language=data.get("language"), 67 | country=data.get("country"), 68 | appVersion=data.get("appVersion"), 69 | appBuild=data.get("appBuild"), 70 | customData=data.get("customData"), 71 | privateCustomData=data.get("privateCustomData"), 72 | createdDate=created_date, 73 | lastSeenDate=last_seen_date, 74 | platform=data.get("platform"), 75 | platformVersion=data.get("platformVersion"), 76 | deviceModel=data.get("deviceModel"), 77 | sdkType=data.get("sdkType"), 78 | sdkVersion=data.get("sdkVersion"), 79 | sdkPlatform=data.get("sdkPlatform"), 80 | ) 81 | 82 | @staticmethod 83 | def _set_custom_value(custom_data: Dict[str, Any], key: str, value: Optional[Any]): 84 | """ 85 | Sets a custom value in the custom data dictionary. Custom data properties can 86 | only be strings, numbers, or booleans. Nested dictionaries and lists are 87 | not permitted. 88 | 89 | Invalid values will generate an error 90 | """ 91 | if key and (value is None or isinstance(value, (str, int, float, bool))): 92 | custom_data[key] = value 93 | else: 94 | raise InvalidContextError( 95 | "Custom property values must be strings, numbers, booleans or None" 96 | ) 97 | 98 | @staticmethod 99 | def create_user_from_context( 100 | context: Optional[EvaluationContext], 101 | ) -> "DevCycleUser": 102 | """ 103 | Builds a DevCycleUser instance from the evaluation context. Will raise a TargetingKeyMissingError if 104 | the context does not contain a valid targeting key or user_id attribute 105 | 106 | :param context: The evaluation context to build the user from 107 | :return: A DevCycleUser instance 108 | """ 109 | user_id = None 110 | 111 | if context: 112 | if context.targeting_key: 113 | user_id = context.targeting_key 114 | elif context.attributes and "user_id" in context.attributes.keys(): 115 | user_id = context.attributes["user_id"] 116 | 117 | if not user_id or not isinstance(user_id, str): 118 | raise TargetingKeyMissingError( 119 | "DevCycle: Evaluation context does not contain a valid targeting key or user_id attribute" 120 | ) 121 | 122 | user = DevCycleUser(user_id=user_id) 123 | custom_data: Dict[str, Any] = {} 124 | private_custom_data: Dict[str, Any] = {} 125 | if context and context.attributes: 126 | for key, value in context.attributes.items(): 127 | if key == "user_id": 128 | continue 129 | 130 | if value is not None: 131 | if key == "email" and isinstance(value, str): 132 | user.email = value 133 | elif key == "name" and isinstance(value, str): 134 | user.name = value 135 | elif key == "language" and isinstance(value, str): 136 | user.language = value 137 | elif key == "country" and isinstance(value, str): 138 | user.country = value 139 | elif key == "appVersion" and isinstance(value, str): 140 | user.appVersion = value 141 | elif key == "appBuild" and isinstance(value, str): 142 | user.appBuild = value 143 | elif key == "deviceModel" and isinstance(value, str): 144 | user.deviceModel = value 145 | elif key == "customData" and isinstance(value, dict): 146 | for k, v in value.items(): 147 | DevCycleUser._set_custom_value(custom_data, k, v) 148 | elif key == "privateCustomData" and isinstance(value, dict): 149 | for k, v in value.items(): 150 | DevCycleUser._set_custom_value(private_custom_data, k, v) 151 | else: 152 | # unrecognized keys are just added to public custom data 153 | DevCycleUser._set_custom_value(custom_data, key, value) 154 | 155 | if custom_data: 156 | user.customData = custom_data 157 | 158 | if private_custom_data: 159 | user.privateCustomData = private_custom_data 160 | 161 | user.sdkPlatform = "python-of" 162 | return user 163 | -------------------------------------------------------------------------------- /devcycle_python_sdk/models/variable.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N815 2 | from dataclasses import dataclass 3 | from typing import Optional, Any 4 | 5 | 6 | class TypeEnum: 7 | BOOLEAN = "Boolean" 8 | STRING = "String" 9 | NUMBER = "Number" 10 | JSON = "JSON" 11 | 12 | 13 | def determine_variable_type(value: Any) -> str: 14 | if isinstance(value, bool): 15 | return TypeEnum.BOOLEAN 16 | elif isinstance(value, str): 17 | return TypeEnum.STRING 18 | elif isinstance(value, (int, float)): 19 | return TypeEnum.NUMBER 20 | elif isinstance(value, dict): 21 | return TypeEnum.JSON 22 | else: 23 | raise TypeError(f"Unsupported type: {type(value)}") 24 | 25 | 26 | @dataclass(order=False) 27 | class Variable: 28 | _id: Optional[str] 29 | key: str 30 | type: str 31 | value: Any = None 32 | isDefaulted: Optional[bool] = False 33 | defaultValue: Any = None 34 | evalReason: Optional[str] = None 35 | 36 | def to_json(self): 37 | return { 38 | key: getattr(self, key) 39 | for key in self.__dataclass_fields__ 40 | if getattr(self, key) is not None 41 | } 42 | 43 | @classmethod 44 | def from_json(cls, data: dict) -> "Variable": 45 | return cls( 46 | _id=data["_id"], 47 | key=data["key"], 48 | type=data["type"], 49 | value=data["value"], 50 | isDefaulted=data.get("isDefaulted", None), 51 | defaultValue=data.get("defaultValue"), 52 | evalReason=data.get("evalReason"), 53 | ) 54 | 55 | @staticmethod 56 | def create_default_variable(key: str, default_value: Any) -> "Variable": 57 | var_type = determine_variable_type(default_value) 58 | return Variable( 59 | _id=None, 60 | key=key, 61 | type=var_type, 62 | value=default_value, 63 | defaultValue=default_value, 64 | isDefaulted=True, 65 | ) 66 | -------------------------------------------------------------------------------- /devcycle_python_sdk/open_feature_provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/open_feature_provider/__init__.py -------------------------------------------------------------------------------- /devcycle_python_sdk/open_feature_provider/provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from typing import Any, Optional, Union, List 5 | 6 | from devcycle_python_sdk import AbstractDevCycleClient 7 | from devcycle_python_sdk.models.user import DevCycleUser 8 | 9 | from openfeature.provider import AbstractProvider 10 | from openfeature.provider.metadata import Metadata 11 | from openfeature.evaluation_context import EvaluationContext 12 | from openfeature.flag_evaluation import FlagResolutionDetails, Reason 13 | from openfeature.exception import ( 14 | ErrorCode, 15 | InvalidContextError, 16 | TypeMismatchError, 17 | GeneralError, 18 | ) 19 | from openfeature.hook import Hook 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class DevCycleProvider(AbstractProvider): 25 | """ 26 | Openfeature provider wrapper for the DevCycle SDK. 27 | 28 | Can be initialized with either a DevCycleLocalClient or DevCycleCloudClient instance. 29 | """ 30 | 31 | def __init__(self, devcycle_client: AbstractDevCycleClient): 32 | self.client = devcycle_client 33 | self.meta_data = Metadata(name=f"DevCycle {self.client.get_sdk_platform()}") 34 | 35 | def initialize(self, evaluation_context: EvaluationContext) -> None: 36 | timeout = 2 37 | start_time = time.time() 38 | 39 | # Wait for the client to be initialized or timeout 40 | while not self.client.is_initialized(): 41 | if time.time() - start_time > timeout: 42 | raise GeneralError( 43 | f"DevCycleProvider initialization timed out after {timeout} seconds" 44 | ) 45 | time.sleep(0.1) # Sleep briefly to avoid busy waiting 46 | 47 | if self.client.is_initialized(): 48 | logger.debug("DevCycleProvider initialized successfully") 49 | 50 | def shutdown(self) -> None: 51 | self.client.close() 52 | 53 | def get_metadata(self) -> Metadata: 54 | return self.meta_data 55 | 56 | def get_provider_hooks(self) -> List[Hook]: 57 | return [] 58 | 59 | def _resolve( 60 | self, 61 | flag_key: str, 62 | default_value: Any, 63 | evaluation_context: Optional[EvaluationContext] = None, 64 | ) -> FlagResolutionDetails[Any]: 65 | if self.client.is_initialized(): 66 | try: 67 | user: DevCycleUser = DevCycleUser.create_user_from_context( 68 | evaluation_context 69 | ) 70 | 71 | variable = self.client.variable( 72 | key=flag_key, user=user, default_value=default_value 73 | ) 74 | 75 | if variable is None: 76 | # this technically should never happen 77 | # as the DevCycle client should at least return a default Variable instance 78 | return FlagResolutionDetails( 79 | value=default_value, 80 | reason=Reason.DEFAULT, 81 | ) 82 | else: 83 | return FlagResolutionDetails( 84 | value=variable.value, 85 | reason=( 86 | Reason.DEFAULT 87 | if variable.isDefaulted 88 | else Reason.TARGETING_MATCH 89 | ), 90 | ) 91 | except ValueError as e: 92 | # occurs if the key or default value is None 93 | raise InvalidContextError(str(e)) 94 | else: 95 | return FlagResolutionDetails( 96 | value=default_value, 97 | reason=Reason.ERROR, 98 | error_code=ErrorCode.PROVIDER_NOT_READY, 99 | ) 100 | 101 | def resolve_boolean_details( 102 | self, 103 | flag_key: str, 104 | default_value: bool, 105 | evaluation_context: Optional[EvaluationContext] = None, 106 | ) -> FlagResolutionDetails[bool]: 107 | return self._resolve(flag_key, default_value, evaluation_context) 108 | 109 | def resolve_string_details( 110 | self, 111 | flag_key: str, 112 | default_value: str, 113 | evaluation_context: Optional[EvaluationContext] = None, 114 | ) -> FlagResolutionDetails[str]: 115 | return self._resolve(flag_key, default_value, evaluation_context) 116 | 117 | def resolve_integer_details( 118 | self, 119 | flag_key: str, 120 | default_value: int, 121 | evaluation_context: Optional[EvaluationContext] = None, 122 | ) -> FlagResolutionDetails[int]: 123 | return self._resolve(flag_key, default_value, evaluation_context) 124 | 125 | def resolve_float_details( 126 | self, 127 | flag_key: str, 128 | default_value: float, 129 | evaluation_context: Optional[EvaluationContext] = None, 130 | ) -> FlagResolutionDetails[float]: 131 | return self._resolve(flag_key, default_value, evaluation_context) 132 | 133 | def resolve_object_details( 134 | self, 135 | flag_key: str, 136 | default_value: Union[dict, list], 137 | evaluation_context: Optional[EvaluationContext] = None, 138 | ) -> FlagResolutionDetails[Union[dict, list]]: 139 | if not isinstance(default_value, dict): 140 | raise TypeMismatchError("Default value must be a flat dictionary") 141 | 142 | if default_value: 143 | for k, v in default_value.items(): 144 | if not isinstance(v, (str, int, float, bool)) or v is None: 145 | raise TypeMismatchError( 146 | "Default value must be a flat dictionary containing only strings, numbers, booleans or None values" 147 | ) 148 | 149 | return self._resolve(flag_key, default_value, evaluation_context) 150 | -------------------------------------------------------------------------------- /devcycle_python_sdk/options.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Optional, Dict, Any 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class DevCycleCloudOptions: 8 | """ 9 | Options for configuring the DevCycle Cloud SDK. 10 | """ 11 | 12 | def __init__( 13 | self, 14 | enable_edge_db: bool = False, 15 | bucketing_api_uri: str = "https://bucketing-api.devcycle.com/", 16 | request_timeout: int = 5, # seconds 17 | request_retries: int = 5, 18 | retry_delay: int = 200, # milliseconds 19 | ): 20 | self.enable_edge_db = enable_edge_db 21 | self.bucketing_api_uri = bucketing_api_uri 22 | self.request_timeout = request_timeout 23 | self.request_retries = request_retries 24 | self.retry_delay = retry_delay 25 | 26 | 27 | class DevCycleLocalOptions: 28 | """ 29 | Options for configuring the DevCycle Local Bucketing SDK. 30 | """ 31 | 32 | def __init__( 33 | self, 34 | config_cdn_uri: str = "https://config-cdn.devcycle.com/", 35 | config_request_timeout_ms: int = 5000, 36 | config_polling_interval_ms: int = 1000, 37 | config_retry_delay_ms: int = 200, # milliseconds 38 | on_client_initialized: Optional[Callable] = None, 39 | events_api_uri: str = "https://events.devcycle.com/", 40 | max_event_queue_size: int = 2000, 41 | event_flush_interval_ms: int = 10000, 42 | flush_event_queue_size: int = 1000, 43 | event_request_chunk_size: int = 100, 44 | event_request_timeout_ms: int = 10000, 45 | event_retry_delay_ms: int = 200, # milliseconds 46 | disable_automatic_event_logging: bool = False, 47 | disable_custom_event_logging: bool = False, 48 | enable_beta_realtime_updates: bool = False, 49 | disable_realtime_updates: bool = False, 50 | ): 51 | self.events_api_uri = events_api_uri 52 | self.config_cdn_uri = config_cdn_uri 53 | self.config_request_timeout_ms = config_request_timeout_ms 54 | self.config_polling_interval_ms = config_polling_interval_ms 55 | self.max_event_queue_size = max_event_queue_size 56 | self.event_flush_interval_ms = event_flush_interval_ms 57 | self.flush_event_queue_size = flush_event_queue_size 58 | self.event_request_chunk_size = event_request_chunk_size 59 | self.disable_automatic_event_logging = disable_automatic_event_logging 60 | self.disable_custom_event_logging = disable_custom_event_logging 61 | self.config_retry_delay_ms = config_retry_delay_ms 62 | self.on_client_initialized = on_client_initialized 63 | self.event_request_timeout_ms = event_request_timeout_ms 64 | self.event_retry_delay_ms = event_retry_delay_ms 65 | self.disable_realtime_updates = disable_realtime_updates 66 | 67 | if enable_beta_realtime_updates: 68 | logger.warning( 69 | "DevCycle: `enable_beta_realtime_updates` is deprecated and will be removed in a future release.", 70 | ) 71 | 72 | if self.flush_event_queue_size >= self.max_event_queue_size: 73 | logger.warning( 74 | f"DevCycle: flush_event_queue_size: {self.flush_event_queue_size} must be smaller than max_event_queue_size: {self.max_event_queue_size}" 75 | ) 76 | self.flush_event_queue_size = self.max_event_queue_size - 1 77 | 78 | if self.event_request_chunk_size > self.flush_event_queue_size: 79 | logger.warning( 80 | f"DevCycle: event_request_chunk_size: {self.event_request_chunk_size} must be smaller than flush_event_queue_size: {self.flush_event_queue_size}" 81 | ) 82 | self.event_request_chunk_size = 100 83 | 84 | if self.event_request_chunk_size > self.max_event_queue_size: 85 | logger.warning( 86 | f"DevCycle: event_request_chunk_size: {self.event_request_chunk_size} must be smaller than max_event_queue_size: { self.max_event_queue_size}" 87 | ) 88 | self.event_request_chunk_size = 100 89 | 90 | if self.flush_event_queue_size > 20000: 91 | logger.warning( 92 | f"DevCycle: flush_event_queue_size: {self.flush_event_queue_size} must be smaller than 20,000" 93 | ) 94 | self.flush_event_queue_size = 20000 95 | 96 | if self.max_event_queue_size > 20000: 97 | logger.warning( 98 | f"DevCycle: max_event_queue_size: {self.max_event_queue_size} must be smaller than 20,000" 99 | ) 100 | self.max_event_queue_size = 20000 101 | 102 | def event_queue_options(self) -> Dict[str, Any]: 103 | """ 104 | Returns a read-only view of the options that are relevant to the event subsystem 105 | """ 106 | return { 107 | "flushEventsMS": self.event_flush_interval_ms, 108 | "disableAutomaticEventLogging": self.disable_automatic_event_logging, 109 | "disableCustomEventLogging": self.disable_custom_event_logging, 110 | "maxEventsPerFlush": self.max_event_queue_size, 111 | "minEventsPerFlush": self.flush_event_queue_size, 112 | "eventRequestChunkSize": self.event_request_chunk_size, 113 | "eventsAPIBasePath": self.events_api_uri, 114 | } 115 | -------------------------------------------------------------------------------- /devcycle_python_sdk/protobuf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/protobuf/__init__.py -------------------------------------------------------------------------------- /devcycle_python_sdk/protobuf/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import math 4 | 5 | from typing import Any, Optional 6 | 7 | from devcycle_python_sdk.models.variable import TypeEnum, Variable 8 | from devcycle_python_sdk.models.user import DevCycleUser 9 | 10 | import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def create_nullable_double(val: Optional[float]) -> pb2.NullableDouble: # type: ignore 16 | if val and not math.isnan(val): 17 | return pb2.NullableDouble(value=val, isNull=False) # type: ignore 18 | else: 19 | return pb2.NullableDouble(isNull=True) # type: ignore 20 | 21 | 22 | def create_nullable_string(val: Optional[str]) -> pb2.NullableString: # type: ignore 23 | if val is None: 24 | return pb2.NullableString(isNull=True) # type: ignore 25 | else: 26 | return pb2.NullableString(value=val, isNull=False) # type: ignore 27 | 28 | 29 | def create_nullable_custom_data(val: Optional[dict]) -> pb2.NullableCustomData: # type: ignore 30 | if val: 31 | values = dict() 32 | for key, value in val.items(): 33 | if value is None: 34 | values[key] = pb2.CustomDataValue(type=pb2.CustomDataType.Null) # type: ignore 35 | elif isinstance(value, bool): 36 | values[key] = pb2.CustomDataValue(type=pb2.CustomDataType.Bool, boolValue=value) # type: ignore 37 | elif isinstance(value, str): 38 | values[key] = pb2.CustomDataValue(type=pb2.CustomDataType.Str, stringValue=value) # type: ignore 39 | elif isinstance(value, (int, float)): 40 | values[key] = pb2.CustomDataValue(type=pb2.CustomDataType.Num, doubleValue=value) # type: ignore 41 | else: 42 | logger.warning( 43 | f"Custom Data contains data type that can't be written, will be ignored. Key: {key}, Type: {str(type(value))}" 44 | ) 45 | 46 | return pb2.NullableCustomData(value=values, isNull=False) # type: ignore 47 | else: 48 | return pb2.NullableCustomData(isNull=True) # type: ignore 49 | 50 | 51 | def convert_type_enum_to_variable_type(var_type: str) -> pb2.VariableType_PB: 52 | if var_type == TypeEnum.BOOLEAN: 53 | return pb2.VariableType_PB.Boolean 54 | elif var_type == TypeEnum.STRING: 55 | return pb2.VariableType_PB.String 56 | elif var_type == TypeEnum.NUMBER: 57 | return pb2.VariableType_PB.Number 58 | elif var_type == TypeEnum.JSON: 59 | return pb2.VariableType_PB.JSON 60 | else: 61 | raise ValueError("Unknown type: " + str(var_type)) 62 | 63 | 64 | def create_dvcuser_pb(user: DevCycleUser) -> pb2.DVCUser_PB: # type: ignore 65 | app_build = float("nan") 66 | if user.appBuild: 67 | try: 68 | app_build = float(user.appBuild) 69 | except ValueError: 70 | pass 71 | 72 | return pb2.DVCUser_PB( # type: ignore 73 | user_id=user.user_id, 74 | email=create_nullable_string(user.email), 75 | name=create_nullable_string(user.name), 76 | language=create_nullable_string(user.language), 77 | country=create_nullable_string(user.country), 78 | appVersion=create_nullable_string(user.appVersion), 79 | appBuild=create_nullable_double(app_build), 80 | customData=create_nullable_custom_data(user.customData), 81 | privateCustomData=create_nullable_custom_data(user.privateCustomData), 82 | ) 83 | 84 | 85 | def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Variable: # type: ignore 86 | if sdk_variable.type == pb2.VariableType_PB.Boolean: # type: ignore 87 | return Variable( 88 | _id=None, 89 | value=sdk_variable.boolValue, 90 | key=sdk_variable.key, 91 | type=TypeEnum.BOOLEAN, 92 | isDefaulted=False, 93 | defaultValue=default_value, 94 | ) 95 | 96 | elif sdk_variable.type == pb2.VariableType_PB.String: # type: ignore 97 | return Variable( 98 | _id=None, 99 | value=sdk_variable.stringValue, 100 | key=sdk_variable.key, 101 | type=TypeEnum.STRING, 102 | isDefaulted=False, 103 | defaultValue=default_value, 104 | ) 105 | 106 | elif sdk_variable.type == pb2.VariableType_PB.Number: # type: ignore 107 | return Variable( 108 | _id=None, 109 | value=sdk_variable.doubleValue, 110 | key=sdk_variable.key, 111 | type=TypeEnum.NUMBER, 112 | isDefaulted=False, 113 | defaultValue=default_value, 114 | ) 115 | 116 | elif sdk_variable.type == pb2.VariableType_PB.JSON: # type: ignore 117 | json_data = json.loads(sdk_variable.stringValue) 118 | 119 | return Variable( 120 | _id=None, 121 | value=json_data, 122 | key=sdk_variable.key, 123 | type=TypeEnum.JSON, 124 | isDefaulted=False, 125 | defaultValue=default_value, 126 | ) 127 | 128 | else: 129 | raise ValueError("Unknown type: " + sdk_variable.type) 130 | -------------------------------------------------------------------------------- /devcycle_python_sdk/protobuf/variableForUserParams_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: variableForUserParams.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf.internal import builder as _builder 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bvariableForUserParams.proto\"/\n\x0eNullableString\x12\r\n\x05value\x18\x01 \x01(\t\x12\x0e\n\x06isNull\x18\x02 \x01(\x08\"/\n\x0eNullableDouble\x12\r\n\x05value\x18\x01 \x01(\x01\x12\x0e\n\x06isNull\x18\x02 \x01(\x08\"m\n\x0f\x43ustomDataValue\x12\x1d\n\x04type\x18\x01 \x01(\x0e\x32\x0f.CustomDataType\x12\x11\n\tboolValue\x18\x02 \x01(\x08\x12\x13\n\x0b\x64oubleValue\x18\x03 \x01(\x01\x12\x13\n\x0bstringValue\x18\x04 \x01(\t\"\x93\x01\n\x12NullableCustomData\x12-\n\x05value\x18\x01 \x03(\x0b\x32\x1e.NullableCustomData.ValueEntry\x12\x0e\n\x06isNull\x18\x02 \x01(\x08\x1a>\n\nValueEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1f\n\x05value\x18\x02 \x01(\x0b\x32\x10.CustomDataValue:\x02\x38\x01\"\x9c\x01\n\x18VariableForUserParams_PB\x12\x0e\n\x06sdkKey\x18\x01 \x01(\t\x12\x13\n\x0bvariableKey\x18\x02 \x01(\t\x12&\n\x0cvariableType\x18\x03 \x01(\x0e\x32\x10.VariableType_PB\x12\x19\n\x04user\x18\x04 \x01(\x0b\x32\x0b.DVCUser_PB\x12\x18\n\x10shouldTrackEvent\x18\x05 \x01(\x08\"\xe8\x02\n\nDVCUser_PB\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x1e\n\x05\x65mail\x18\x02 \x01(\x0b\x32\x0f.NullableString\x12\x1d\n\x04name\x18\x03 \x01(\x0b\x32\x0f.NullableString\x12!\n\x08language\x18\x04 \x01(\x0b\x32\x0f.NullableString\x12 \n\x07\x63ountry\x18\x05 \x01(\x0b\x32\x0f.NullableString\x12!\n\x08\x61ppBuild\x18\x06 \x01(\x0b\x32\x0f.NullableDouble\x12#\n\nappVersion\x18\x07 \x01(\x0b\x32\x0f.NullableString\x12$\n\x0b\x64\x65viceModel\x18\x08 \x01(\x0b\x32\x0f.NullableString\x12\'\n\ncustomData\x18\t \x01(\x0b\x32\x13.NullableCustomData\x12.\n\x11privateCustomData\x18\n \x01(\x0b\x32\x13.NullableCustomData\"\xac\x01\n\x0eSDKVariable_PB\x12\x0b\n\x03_id\x18\x01 \x01(\t\x12\x1e\n\x04type\x18\x02 \x01(\x0e\x32\x10.VariableType_PB\x12\x0b\n\x03key\x18\x03 \x01(\t\x12\x11\n\tboolValue\x18\x04 \x01(\x08\x12\x13\n\x0b\x64oubleValue\x18\x05 \x01(\x01\x12\x13\n\x0bstringValue\x18\x06 \x01(\t\x12#\n\nevalReason\x18\x07 \x01(\x0b\x32\x0f.NullableString*@\n\x0fVariableType_PB\x12\x0b\n\x07\x42oolean\x10\x00\x12\n\n\x06Number\x10\x01\x12\n\n\x06String\x10\x02\x12\x08\n\x04JSON\x10\x03*6\n\x0e\x43ustomDataType\x12\x08\n\x04\x42ool\x10\x00\x12\x07\n\x03Num\x10\x01\x12\x07\n\x03Str\x10\x02\x12\x08\n\x04Null\x10\x03\x42X\n&com.devcycle.sdk.server.local.protobufP\x01Z\x07./proto\xaa\x02\"DevCycle.SDK.Server.Local.Protobufb\x06proto3') 17 | 18 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) 19 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'variableForUserParams_pb2', globals()) 20 | if _descriptor._USE_C_DESCRIPTORS == False: 21 | 22 | DESCRIPTOR._options = None 23 | DESCRIPTOR._serialized_options = b'\n&com.devcycle.sdk.server.local.protobufP\001Z\007./proto\252\002\"DevCycle.SDK.Server.Local.Protobuf' 24 | _NULLABLECUSTOMDATA_VALUEENTRY._options = None 25 | _NULLABLECUSTOMDATA_VALUEENTRY._serialized_options = b'8\001' 26 | _VARIABLETYPE_PB._serialized_start=1087 27 | _VARIABLETYPE_PB._serialized_end=1151 28 | _CUSTOMDATATYPE._serialized_start=1153 29 | _CUSTOMDATATYPE._serialized_end=1207 30 | _NULLABLESTRING._serialized_start=31 31 | _NULLABLESTRING._serialized_end=78 32 | _NULLABLEDOUBLE._serialized_start=80 33 | _NULLABLEDOUBLE._serialized_end=127 34 | _CUSTOMDATAVALUE._serialized_start=129 35 | _CUSTOMDATAVALUE._serialized_end=238 36 | _NULLABLECUSTOMDATA._serialized_start=241 37 | _NULLABLECUSTOMDATA._serialized_end=388 38 | _NULLABLECUSTOMDATA_VALUEENTRY._serialized_start=326 39 | _NULLABLECUSTOMDATA_VALUEENTRY._serialized_end=388 40 | _VARIABLEFORUSERPARAMS_PB._serialized_start=391 41 | _VARIABLEFORUSERPARAMS_PB._serialized_end=547 42 | _DVCUSER_PB._serialized_start=550 43 | _DVCUSER_PB._serialized_end=910 44 | _SDKVARIABLE_PB._serialized_start=913 45 | _SDKVARIABLE_PB._serialized_end=1085 46 | # @@protoc_insertion_point(module_scope) 47 | -------------------------------------------------------------------------------- /devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi: -------------------------------------------------------------------------------- 1 | from google.protobuf.internal import containers as _containers 2 | from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper 3 | from google.protobuf import descriptor as _descriptor 4 | from google.protobuf import message as _message 5 | from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union 6 | 7 | Bool: CustomDataType 8 | Boolean: VariableType_PB 9 | DESCRIPTOR: _descriptor.FileDescriptor 10 | JSON: VariableType_PB 11 | Null: CustomDataType 12 | Num: CustomDataType 13 | Number: VariableType_PB 14 | Str: CustomDataType 15 | String: VariableType_PB 16 | 17 | class CustomDataValue(_message.Message): 18 | __slots__ = ["boolValue", "doubleValue", "stringValue", "type"] 19 | BOOLVALUE_FIELD_NUMBER: _ClassVar[int] 20 | DOUBLEVALUE_FIELD_NUMBER: _ClassVar[int] 21 | STRINGVALUE_FIELD_NUMBER: _ClassVar[int] 22 | TYPE_FIELD_NUMBER: _ClassVar[int] 23 | boolValue: bool 24 | doubleValue: float 25 | stringValue: str 26 | type: CustomDataType 27 | def __init__(self, type: _Optional[_Union[CustomDataType, str]] = ..., boolValue: bool = ..., doubleValue: _Optional[float] = ..., stringValue: _Optional[str] = ...) -> None: ... 28 | 29 | class DVCUser_PB(_message.Message): 30 | __slots__ = ["appBuild", "appVersion", "country", "customData", "deviceModel", "email", "language", "name", "privateCustomData", "user_id"] 31 | APPBUILD_FIELD_NUMBER: _ClassVar[int] 32 | APPVERSION_FIELD_NUMBER: _ClassVar[int] 33 | COUNTRY_FIELD_NUMBER: _ClassVar[int] 34 | CUSTOMDATA_FIELD_NUMBER: _ClassVar[int] 35 | DEVICEMODEL_FIELD_NUMBER: _ClassVar[int] 36 | EMAIL_FIELD_NUMBER: _ClassVar[int] 37 | LANGUAGE_FIELD_NUMBER: _ClassVar[int] 38 | NAME_FIELD_NUMBER: _ClassVar[int] 39 | PRIVATECUSTOMDATA_FIELD_NUMBER: _ClassVar[int] 40 | USER_ID_FIELD_NUMBER: _ClassVar[int] 41 | appBuild: NullableDouble 42 | appVersion: NullableString 43 | country: NullableString 44 | customData: NullableCustomData 45 | deviceModel: NullableString 46 | email: NullableString 47 | language: NullableString 48 | name: NullableString 49 | privateCustomData: NullableCustomData 50 | user_id: str 51 | def __init__(self, user_id: _Optional[str] = ..., email: _Optional[_Union[NullableString, _Mapping]] = ..., name: _Optional[_Union[NullableString, _Mapping]] = ..., language: _Optional[_Union[NullableString, _Mapping]] = ..., country: _Optional[_Union[NullableString, _Mapping]] = ..., appBuild: _Optional[_Union[NullableDouble, _Mapping]] = ..., appVersion: _Optional[_Union[NullableString, _Mapping]] = ..., deviceModel: _Optional[_Union[NullableString, _Mapping]] = ..., customData: _Optional[_Union[NullableCustomData, _Mapping]] = ..., privateCustomData: _Optional[_Union[NullableCustomData, _Mapping]] = ...) -> None: ... 52 | 53 | class NullableCustomData(_message.Message): 54 | __slots__ = ["isNull", "value"] 55 | class ValueEntry(_message.Message): 56 | __slots__ = ["key", "value"] 57 | KEY_FIELD_NUMBER: _ClassVar[int] 58 | VALUE_FIELD_NUMBER: _ClassVar[int] 59 | key: str 60 | value: CustomDataValue 61 | def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[CustomDataValue, _Mapping]] = ...) -> None: ... 62 | ISNULL_FIELD_NUMBER: _ClassVar[int] 63 | VALUE_FIELD_NUMBER: _ClassVar[int] 64 | isNull: bool 65 | value: _containers.MessageMap[str, CustomDataValue] 66 | def __init__(self, value: _Optional[_Mapping[str, CustomDataValue]] = ..., isNull: bool = ...) -> None: ... 67 | 68 | class NullableDouble(_message.Message): 69 | __slots__ = ["isNull", "value"] 70 | ISNULL_FIELD_NUMBER: _ClassVar[int] 71 | VALUE_FIELD_NUMBER: _ClassVar[int] 72 | isNull: bool 73 | value: float 74 | def __init__(self, value: _Optional[float] = ..., isNull: bool = ...) -> None: ... 75 | 76 | class NullableString(_message.Message): 77 | __slots__ = ["isNull", "value"] 78 | ISNULL_FIELD_NUMBER: _ClassVar[int] 79 | VALUE_FIELD_NUMBER: _ClassVar[int] 80 | isNull: bool 81 | value: str 82 | def __init__(self, value: _Optional[str] = ..., isNull: bool = ...) -> None: ... 83 | 84 | class SDKVariable_PB(_message.Message): 85 | __slots__ = ["_id", "boolValue", "doubleValue", "evalReason", "key", "stringValue", "type"] 86 | BOOLVALUE_FIELD_NUMBER: _ClassVar[int] 87 | DOUBLEVALUE_FIELD_NUMBER: _ClassVar[int] 88 | EVALREASON_FIELD_NUMBER: _ClassVar[int] 89 | KEY_FIELD_NUMBER: _ClassVar[int] 90 | STRINGVALUE_FIELD_NUMBER: _ClassVar[int] 91 | TYPE_FIELD_NUMBER: _ClassVar[int] 92 | _ID_FIELD_NUMBER: _ClassVar[int] 93 | _id: str 94 | boolValue: bool 95 | doubleValue: float 96 | evalReason: NullableString 97 | key: str 98 | stringValue: str 99 | type: VariableType_PB 100 | def __init__(self, _id: _Optional[str] = ..., type: _Optional[_Union[VariableType_PB, str]] = ..., key: _Optional[str] = ..., boolValue: bool = ..., doubleValue: _Optional[float] = ..., stringValue: _Optional[str] = ..., evalReason: _Optional[_Union[NullableString, _Mapping]] = ...) -> None: ... 101 | 102 | class VariableForUserParams_PB(_message.Message): 103 | __slots__ = ["sdkKey", "shouldTrackEvent", "user", "variableKey", "variableType"] 104 | SDKKEY_FIELD_NUMBER: _ClassVar[int] 105 | SHOULDTRACKEVENT_FIELD_NUMBER: _ClassVar[int] 106 | USER_FIELD_NUMBER: _ClassVar[int] 107 | VARIABLEKEY_FIELD_NUMBER: _ClassVar[int] 108 | VARIABLETYPE_FIELD_NUMBER: _ClassVar[int] 109 | sdkKey: str 110 | shouldTrackEvent: bool 111 | user: DVCUser_PB 112 | variableKey: str 113 | variableType: VariableType_PB 114 | def __init__(self, sdkKey: _Optional[str] = ..., variableKey: _Optional[str] = ..., variableType: _Optional[_Union[VariableType_PB, str]] = ..., user: _Optional[_Union[DVCUser_PB, _Mapping]] = ..., shouldTrackEvent: bool = ...) -> None: ... 115 | 116 | class VariableType_PB(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): 117 | __slots__ = [] 118 | 119 | class CustomDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): 120 | __slots__ = [] 121 | -------------------------------------------------------------------------------- /devcycle_python_sdk/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/devcycle_python_sdk/py.typed -------------------------------------------------------------------------------- /devcycle_python_sdk/util/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /devcycle_python_sdk/util/strings.py: -------------------------------------------------------------------------------- 1 | def slash_join(*args) -> str: 2 | """ 3 | Assembles a string, concatenating the arguments with a single slash between them 4 | removing any leading or trailing slashes from the arguments 5 | """ 6 | return "/".join(str(arg).strip("/") for arg in args) 7 | -------------------------------------------------------------------------------- /devcycle_python_sdk/util/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def sdk_version() -> str: 5 | """ 6 | Returns the current version of this SDK as a semantic version string 7 | """ 8 | with open(os.path.dirname(__file__) + "/../VERSION.txt", "r") as version_file: 9 | return version_file.read().strip() 10 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/example/__init__.py -------------------------------------------------------------------------------- /example/cloud_client_example.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | 5 | from devcycle_python_sdk import DevCycleCloudClient, DevCycleCloudOptions 6 | 7 | from devcycle_python_sdk.models.user import DevCycleUser 8 | from devcycle_python_sdk.models.event import DevCycleEvent, EventType 9 | 10 | VARIABLE_KEY = "test-boolean-variable" 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | """ 17 | Sample usage of the Python Server SDK using Cloud Bucketing. 18 | For a Django specific sample app, please see https://github.com/DevCycleHQ/python-django-example-app/ 19 | """ 20 | logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s") 21 | 22 | # create an instance of the DevCycle Client object 23 | server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"] 24 | options = DevCycleCloudOptions(enable_edge_db=True) 25 | client = DevCycleCloudClient(server_sdk_key, options) 26 | 27 | user = DevCycleUser(user_id="test-1234", email="test-user@domain.com", country="US") 28 | 29 | # Use variable_value to access the value of a variable directly 30 | if client.variable_value(user, VARIABLE_KEY, False): 31 | logger.info(f"Variable {VARIABLE_KEY} is enabled") 32 | else: 33 | logger.info(f"Variable {VARIABLE_KEY} is not enabled") 34 | 35 | # DevCycle handles missing or wrongly typed variables by returning the default value 36 | # You can check this explicitly by using the variable method 37 | variable = client.variable(user, VARIABLE_KEY + "-does-not-exist", False) 38 | if variable.isDefaulted: 39 | logger.info(f"Variable {variable.key} is defaulted to {variable.value}") 40 | 41 | try: 42 | # Get all variables by key for user data 43 | all_variables_response = client.all_variables(user) 44 | logger.info(f"All variables:\n{all_variables_response}") 45 | 46 | if VARIABLE_KEY not in all_variables_response: 47 | logger.warning( 48 | f"Variable {VARIABLE_KEY} does not exist - create it in the dashboard for this example" 49 | ) 50 | 51 | # Get all features by key for user data 52 | all_features_response = client.all_features(user) 53 | logger.info(f"All features:\n{all_features_response}") 54 | 55 | # Post a custom event to DevCycle for user 56 | event = DevCycleEvent( 57 | type=EventType.CustomEvent, 58 | target="some.variable.key", 59 | date=datetime.datetime.now(), 60 | ) 61 | client.track(user, event) 62 | 63 | except Exception as e: 64 | logger.exception(f"Exception when calling Devcycle API: {e}\n") 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /example/django-app/README.md: -------------------------------------------------------------------------------- 1 | # DevCycle Python Django Example App 2 | 3 | Welcome to the DevCycle Python Django Example App, for sample usage with the [DevCycle Python Server SDK](https://github.com/DevCycleHQ/python-server-sdk). 4 | To find Python SDK usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage). 5 | 6 | ## Requirements. 7 | 8 | Python 3.7+ and Django 4.2+ 9 | 10 | ## Installation 11 | 12 | ```sh 13 | pip install -r requirements.txt 14 | ``` 15 | (you may need to run `pip` with root permission: `sudo pip install -r requirements.txt`) 16 | 17 | ## Setup 18 | 19 | See the `config/settings.py` file for the configuration of your SDK key in the `DEVCYCLE_SERVER_SDK_KEY` setting. 20 | 21 | ## Client Configuration 22 | 23 | For convenience, a middleware implementation is used to add the DevCycle client to the request object, so you can access it in your views as `request.devcycle`. 24 | 25 | There are two examples of middleware, one for each type of DevCycle SDK: cloud bucketing and local bucketing. The middleware is configured in `config/settings.py`. 26 | 27 | To customize the DevCycle client, you can update the appropriate function in the middleware. See `devcycle_test/middleware.py` for an example. 28 | 29 | ## Usage 30 | 31 | To run the example app: 32 | ```sh 33 | python manage.py migrate 34 | python manage.py runserver 35 | ``` 36 | The server will start on port 8000. You can access the example app at http://localhost:8000. 37 | 38 | 39 | ## Variable Evaluation 40 | 41 | An example of variable evaluation is done in `devcycle_test/views.py` where a user is created and the DevCycle client attached to the request is used to obtain a variable value. 42 | 43 | ```python 44 | if request.devcycle.variable_value(user, variable_key, False): 45 | logger.info(f"{variable_key} is on") 46 | return HttpResponse("Hello, World! Your feature is on!") 47 | ``` -------------------------------------------------------------------------------- /example/django-app/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/example/django-app/config/__init__.py -------------------------------------------------------------------------------- /example/django-app/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for config project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/django-app/config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for config project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from os import environ 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-1pttu4zqyx^v7$=8wt9b-jb3x5oyo&ej%y1zbgz1!gxhnd2r8w" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | CACHES = { 32 | "default": { 33 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 34 | } 35 | } 36 | 37 | 38 | # Application definition 39 | 40 | INSTALLED_APPS = [ 41 | "django.contrib.admin", 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | "django.contrib.messages", 46 | "django.contrib.staticfiles", 47 | "devcycle_test", 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | "django.middleware.security.SecurityMiddleware", 52 | "django.contrib.sessions.middleware.SessionMiddleware", 53 | "django.middleware.common.CommonMiddleware", 54 | "django.middleware.csrf.CsrfViewMiddleware", 55 | "django.contrib.auth.middleware.AuthenticationMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 58 | "devcycle_test.middleware.devcycle_local_middleware", 59 | ] 60 | 61 | ROOT_URLCONF = "config.urls" 62 | 63 | TEMPLATES = [ 64 | { 65 | "BACKEND": "django.template.backends.django.DjangoTemplates", 66 | "DIRS": [], 67 | "APP_DIRS": True, 68 | "OPTIONS": { 69 | "context_processors": [ 70 | "django.template.context_processors.debug", 71 | "django.template.context_processors.request", 72 | "django.contrib.auth.context_processors.auth", 73 | "django.contrib.messages.context_processors.messages", 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = "config.wsgi.application" 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 84 | 85 | DATABASES = { 86 | "default": { 87 | "ENGINE": "django.db.backends.sqlite3", 88 | "NAME": BASE_DIR / "db.sqlite3", 89 | } 90 | } 91 | 92 | 93 | # Password validation 94 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | { 98 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 105 | }, 106 | { 107 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 108 | }, 109 | ] 110 | 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 114 | 115 | LANGUAGE_CODE = "en-us" 116 | 117 | TIME_ZONE = "UTC" 118 | 119 | USE_I18N = True 120 | 121 | USE_TZ = True 122 | 123 | 124 | # Static files (CSS, JavaScript, Images) 125 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 126 | 127 | STATIC_URL = "static/" 128 | 129 | # Default primary key field type 130 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 131 | 132 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 133 | 134 | # DevCycle Settings 135 | DEVCYCLE_SERVER_SDK_KEY = environ["DEVCYCLE_SERVER_SDK_KEY"] 136 | -------------------------------------------------------------------------------- /example/django-app/config/urls.py: -------------------------------------------------------------------------------- 1 | """config URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | urlpatterns = [ 21 | path("admin/", admin.site.urls), 22 | # Main app 23 | path("", include("devcycle_test.urls")), 24 | ] 25 | -------------------------------------------------------------------------------- /example/django-app/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/django-app/devcycle_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/example/django-app/devcycle_test/__init__.py -------------------------------------------------------------------------------- /example/django-app/devcycle_test/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DevCycleTestConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "devcycle_test" 7 | -------------------------------------------------------------------------------- /example/django-app/devcycle_test/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | from devcycle_python_sdk import ( 4 | DevCycleCloudClient, 5 | DevCycleCloudOptions, 6 | DevCycleLocalClient, 7 | DevCycleLocalOptions, 8 | ) 9 | 10 | 11 | def devcycle_cloud_middleware(get_response): 12 | """ 13 | This middleware adds the DevCycle client to the request object passed to 14 | all views as `request.devcycle`. 15 | """ 16 | try: 17 | sdk_key = settings.DEVCYCLE_SERVER_SDK_KEY 18 | except AttributeError: 19 | raise ImproperlyConfigured("Please set DEVCYCLE_SERVER_SDK_KEY in settings.py") 20 | 21 | # Initialize the SDK singleton once here - it will be captured in the closure below 22 | devcycle_client = DevCycleCloudClient(sdk_key, DevCycleCloudOptions()) 23 | 24 | def middleware(request): 25 | request.devcycle = devcycle_client 26 | return get_response(request) 27 | 28 | return middleware 29 | 30 | 31 | def devcycle_local_middleware(get_response): 32 | """ 33 | This middleware adds the DevCycle client to the request object passed to 34 | all views as `request.devcycle`. 35 | """ 36 | try: 37 | sdk_key = settings.DEVCYCLE_SERVER_SDK_KEY 38 | except AttributeError: 39 | raise ImproperlyConfigured("Please set DEVCYCLE_SERVER_SDK_KEY in settings.py") 40 | 41 | # Initialize the SDK singleton once here - it will be captured in the closure below 42 | devcycle_client = DevCycleLocalClient(sdk_key, DevCycleLocalOptions()) 43 | 44 | def middleware(request): 45 | request.devcycle = devcycle_client 46 | return get_response(request) 47 | 48 | return middleware 49 | -------------------------------------------------------------------------------- /example/django-app/devcycle_test/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/example/django-app/devcycle_test/migrations/__init__.py -------------------------------------------------------------------------------- /example/django-app/devcycle_test/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import home_page 3 | 4 | urlpatterns = [ 5 | path("", home_page, name="home"), 6 | ] 7 | -------------------------------------------------------------------------------- /example/django-app/devcycle_test/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | import logging 3 | 4 | from devcycle_python_sdk.models.user import DevCycleUser 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | variable_key = "test-boolean-variable" 9 | 10 | 11 | def home_page(request): 12 | # all functions require user data to be an instance of the User class 13 | user = DevCycleUser( 14 | user_id="test", 15 | email="example@example.ca", 16 | country="CA", 17 | ) 18 | # Check whether a feature flag is on 19 | if request.devcycle.variable_value(user, variable_key, False): 20 | logger.info(f"{variable_key} is on") 21 | return HttpResponse("Hello, World! Your feature is on!") 22 | else: 23 | logger.info(f"{variable_key} is off") 24 | return HttpResponse("Hello, World! Your feature is off.") 25 | -------------------------------------------------------------------------------- /example/django-app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /example/django-app/requirements.txt: -------------------------------------------------------------------------------- 1 | django >= 4.2 2 | devcycle-python-server-sdk >= 3.0.1 3 | -------------------------------------------------------------------------------- /example/local_bucketing_client_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import os 4 | import time 5 | 6 | from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions 7 | from devcycle_python_sdk.models.user import DevCycleUser 8 | from devcycle_python_sdk.models.event import DevCycleEvent, EventType 9 | 10 | VARIABLE_KEY = "python-example-tests" 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | """ 17 | Sample usage of the Python Server SDK using Local Bucketing. 18 | For a Django specific sample app, please see https://github.com/DevCycleHQ/python-django-example-app/ 19 | """ 20 | logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s") 21 | 22 | # create an instance of the DevCycle Client object 23 | server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"] 24 | options = DevCycleLocalOptions() 25 | client = DevCycleLocalClient(server_sdk_key, options) 26 | 27 | # Wait for DevCycle to initialize and load the configuration 28 | for i in range(10): 29 | if client.is_initialized(): 30 | break 31 | logger.info("Waiting for DevCycle to initialize...") 32 | time.sleep(0.5) 33 | else: 34 | logger.error("DevCycle failed to initialize") 35 | exit(1) 36 | 37 | user = DevCycleUser(user_id="test-1234", email="test-user@domain.com", country="US") 38 | 39 | # Use variable_value to access the value of a variable directly 40 | if client.variable_value(user, VARIABLE_KEY, False): 41 | logger.info(f"Variable {VARIABLE_KEY} is enabled") 42 | else: 43 | logger.info(f"Variable {VARIABLE_KEY} is not enabled") 44 | 45 | # DevCycle handles missing or wrongly typed variables by returning the default value 46 | # You can check this explicitly by using the variable method 47 | variable = client.variable(user, VARIABLE_KEY + "-does-not-exist", False) 48 | if variable.isDefaulted: 49 | logger.info(f"Variable {variable.key} is defaulted to {variable.value}") 50 | 51 | try: 52 | # Get all variables by key for user data 53 | all_variables_response = client.all_variables(user) 54 | logger.info(f"All variables:\n{all_variables_response}") 55 | 56 | if VARIABLE_KEY not in all_variables_response: 57 | logger.warning( 58 | f"Variable {VARIABLE_KEY} does not exist - create it in the dashboard for this example" 59 | ) 60 | 61 | # Get all features by key for user data 62 | all_features_response = client.all_features(user) 63 | logger.info(f"All features:\n{all_features_response}") 64 | 65 | # Post a custom event to DevCycle for user 66 | event = DevCycleEvent( 67 | type=EventType.CustomEvent, 68 | target="some.variable.key", 69 | date=datetime.datetime.now(), 70 | ) 71 | client.track(user, event) 72 | except Exception as e: 73 | logger.exception(f"Exception when calling DevCycle API: {e}") 74 | finally: 75 | client.close() 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /example/openfeature_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import os 4 | 5 | from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions 6 | 7 | from openfeature import api 8 | from openfeature.evaluation_context import EvaluationContext 9 | 10 | FLAG_KEY = "test-boolean-variable" 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | """ 17 | Sample usage of the DevCycle OpenFeature Provider along with the Python Server SDK using Local Bucketing. 18 | """ 19 | logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s") 20 | 21 | # create an instance of the DevCycle Client object 22 | server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"] 23 | devcycle_client = DevCycleLocalClient(server_sdk_key, DevCycleLocalOptions()) 24 | 25 | # Wait for DevCycle to initialize and load the configuration 26 | for i in range(10): 27 | if devcycle_client.is_initialized(): 28 | break 29 | logger.info("Waiting for DevCycle to initialize...") 30 | time.sleep(0.5) 31 | else: 32 | logger.error("DevCycle failed to initialize") 33 | exit(1) 34 | 35 | # set the provider for OpenFeature 36 | api.set_provider(devcycle_client.get_openfeature_provider()) 37 | 38 | # get the OpenFeature client 39 | open_feature_client = api.get_client() 40 | 41 | # create the request context for the user 42 | context = EvaluationContext( 43 | targeting_key="test-1234", 44 | attributes={ 45 | "email": "test-user@domain.com", 46 | "name": "Test User", 47 | "language": "en", 48 | "country": "CA", 49 | "appVersion": "1.0.11", 50 | "appBuild": 1000, 51 | "customData": {"custom": "data"}, 52 | "privateCustomData": {"private": "data"}, 53 | }, 54 | ) 55 | 56 | # Look up the value of the flag 57 | if open_feature_client.get_boolean_value(FLAG_KEY, False, context): 58 | logger.info(f"Variable {FLAG_KEY} is enabled") 59 | else: 60 | logger.info(f"Variable {FLAG_KEY} is not enabled") 61 | 62 | # Fetch a JSON object variable 63 | json_object = open_feature_client.get_object_value( 64 | "test-json-variable", {"default": "value"}, context 65 | ) 66 | logger.info(f"JSON Object Value: {json_object}") 67 | 68 | # Retrieve a string variable along with resolution details 69 | details = open_feature_client.get_string_details("doesnt-exist", "default", context) 70 | logger.info(f"String Value: {details.value}") 71 | logger.info(f"Eval Reason: {details.reason}") 72 | 73 | devcycle_client.close() 74 | 75 | 76 | if __name__ == "__main__": 77 | main() 78 | -------------------------------------------------------------------------------- /protobuf/variableForUserParams.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "./proto"; 4 | option csharp_namespace = "DevCycle.SDK.Server.Local.Protobuf"; 5 | option java_package = "com.devcycle.sdk.server.local.protobuf"; 6 | option java_multiple_files = true; 7 | 8 | enum VariableType_PB { 9 | Boolean = 0; 10 | Number = 1; 11 | String = 2; 12 | JSON = 3; 13 | } 14 | 15 | message NullableString { 16 | string value = 1; 17 | bool isNull = 2; 18 | } 19 | 20 | message NullableDouble { 21 | double value = 1; 22 | bool isNull = 2; 23 | } 24 | 25 | enum CustomDataType { 26 | Bool = 0; 27 | Num = 1; 28 | Str = 2; 29 | Null = 3; 30 | } 31 | 32 | message CustomDataValue { 33 | CustomDataType type = 1; 34 | bool boolValue = 2; 35 | double doubleValue = 3; 36 | string stringValue = 4; 37 | } 38 | 39 | message NullableCustomData { 40 | map value = 1; 41 | bool isNull = 2; 42 | } 43 | 44 | message VariableForUserParams_PB { 45 | string sdkKey = 1; 46 | string variableKey = 2; 47 | VariableType_PB variableType = 3; 48 | DVCUser_PB user = 4; 49 | bool shouldTrackEvent = 5; 50 | } 51 | 52 | message DVCUser_PB { 53 | string user_id = 1; 54 | NullableString email = 2; 55 | NullableString name = 3; 56 | NullableString language = 4; 57 | NullableString country = 5; 58 | NullableDouble appBuild = 6; 59 | NullableString appVersion = 7; 60 | NullableString deviceModel = 8; 61 | NullableCustomData customData = 9; 62 | NullableCustomData privateCustomData = 10; 63 | } 64 | 65 | message SDKVariable_PB { 66 | string _id = 1; 67 | VariableType_PB type = 2; 68 | string key = 3; 69 | bool boolValue = 4; 70 | double doubleValue = 5; 71 | string stringValue = 6; 72 | NullableString evalReason = 7; 73 | } 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pytest options 2 | [tool.pytest.ini_options] 3 | minversion = "7.0" 4 | addopts = "--benchmark-skip --showlocals" 5 | 6 | # black options 7 | [tool.black] 8 | target-version = ['py39'] 9 | extend-exclude = '_pb2\.pyi?$' 10 | 11 | # mypy options 12 | [tool.mypy] 13 | python_version = "3.9" 14 | exclude = "django-app" 15 | 16 | # See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-library-stubs-or-py-typed-marker 17 | # To silence errors from within a 3rd party package, use ignore_errors 18 | # To silence "Skipping analyzing X: module is installed, but missing library stubs or py.typed marker" use ignore_missing_imports 19 | [[tool.mypy.overrides]] 20 | module = 'devcycle_python_sdk.protobuf.*' 21 | ignore_errors = true 22 | 23 | [[tool.mypy.overrides]] 24 | module = 'setuptools' 25 | ignore_missing_imports = true 26 | 27 | [[tool.mypy.overrides]] 28 | module = 'test.openfeature.*' 29 | ignore_errors = true 30 | 31 | [[tool.mypy.overrides]] 32 | module = 'ld_eventsource.*' 33 | ignore_errors = true 34 | ignore_missing_imports = true 35 | 36 | 37 | [[tool.mypy.overrides]] 38 | module = 'openfeature.*' 39 | ignore_errors = true 40 | ignore_missing_imports = true 41 | 42 | # ruff options 43 | [tool.ruff] 44 | # https://beta.ruff.rs/docs/rules/ 45 | select = [ 46 | "F", # PyFlakes 47 | "E", # pycodestyle error 48 | "W", # pycodestyle warning 49 | "N", # pep8-naming 50 | "T20", # flake8-print 51 | "RUF100", # ensure noqa comments actually match an error 52 | ] 53 | ignore = [ 54 | "E501", # line too long 55 | ] 56 | exclude = ["variableForUserParams_pb2.pyi"] 57 | [tool.ruff.per-file-ignores] 58 | "__init__.py" = ["F401"] 59 | "variableForUserParams_pb2.py" = ["F821", "N999", "E712"] 60 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | black~=25.1.0 4 | mypy~=1.15.0 5 | mypy-extensions~=1.0.0 6 | pytest~=7.4.0 7 | pytest-benchmark~=4.0.0 8 | responses~=0.25.6 9 | ruff~=0.9.0 10 | types-requests~=2.32.0 11 | types-urllib3~=1.26.25.14 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools >= 21.0.0 2 | urllib3 >= 1.15.1 3 | requests >= 2.32 4 | wasmtime ~= 30.0.0 5 | protobuf >= 4.23.3 6 | openfeature-sdk >= 0.8.0 7 | launchdarkly-eventsource >= 1.2.1 8 | responses >= 0.23.1 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from setuptools import setup, find_packages 4 | import os 5 | 6 | NAME = "devcycle-python-server-sdk" 7 | version_file = open(os.path.join("devcycle_python_sdk", "VERSION.txt")) 8 | VERSION = version_file.read().strip() 9 | 10 | # To install the library, run the following 11 | # 12 | # python setup.py install 13 | # 14 | # prerequisite: setuptools 15 | # http://pypi.python.org/pypi/setuptools 16 | 17 | REQUIRES = [line.strip() for line in open("requirements.txt").readlines()] 18 | 19 | setup( 20 | name=NAME, 21 | version=VERSION, 22 | description="DevCycle Python SDK", 23 | author_email="", 24 | url="https://github.com/devcycleHQ/python-server-sdk", 25 | keywords=["DevCycle"], 26 | install_requires=REQUIRES, 27 | python_requires=">=3.9", 28 | packages=find_packages(), 29 | package_data={ 30 | "": ["VERSION.txt"], 31 | "devcycle_python_sdk": ["py.typed", "bucketing-lib.release.wasm"], 32 | }, 33 | include_package_data=True, 34 | long_description="""\ 35 | The DevCycle Python SDK used for feature management. 36 | """, 37 | ) 38 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/__init__.py -------------------------------------------------------------------------------- /test/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/api/__init__.py -------------------------------------------------------------------------------- /test/api/test_bucketing_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import requests 4 | import responses 5 | from responses.registries import OrderedRegistry 6 | import unittest 7 | import uuid 8 | 9 | from devcycle_python_sdk.api.bucketing_client import BucketingAPIClient 10 | from devcycle_python_sdk.exceptions import ( 11 | CloudClientError, 12 | CloudClientUnauthorizedError, 13 | NotFoundError, 14 | ) 15 | from devcycle_python_sdk.options import DevCycleCloudOptions 16 | from devcycle_python_sdk.models.event import DevCycleEvent 17 | from devcycle_python_sdk.models.feature import Feature 18 | from devcycle_python_sdk.models.user import DevCycleUser 19 | from devcycle_python_sdk.models.variable import Variable 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class BucketingClientTest(unittest.TestCase): 25 | def setUp(self) -> None: 26 | sdk_key = "dvc_server_" + str(uuid.uuid4()) 27 | options = DevCycleCloudOptions(retry_delay=0) 28 | self.test_client = BucketingAPIClient(sdk_key, options) 29 | self.test_user = DevCycleUser(user_id="test_user_id") 30 | 31 | def test_url(self): 32 | result = self.test_client._url("variable", "ABC") 33 | self.assertEqual(result, "https://bucketing-api.devcycle.com/v1/variable/ABC") 34 | 35 | @responses.activate 36 | def test_variable(self): 37 | responses.add( 38 | responses.POST, 39 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 40 | json={ 41 | "_id": "variable_id", 42 | "key": "variable-key", 43 | "type": "variable-type", 44 | "value": "hello world", 45 | }, 46 | ) 47 | result = self.test_client.variable("variable-key", self.test_user) 48 | 49 | self.assertEqual( 50 | result, 51 | Variable( 52 | _id="variable_id", 53 | key="variable-key", 54 | type="variable-type", 55 | value="hello world", 56 | ), 57 | ) 58 | 59 | @responses.activate(registry=OrderedRegistry) 60 | def test_variable_retries(self): 61 | for i in range(self.test_client.options.request_retries): 62 | responses.add( 63 | responses.POST, 64 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 65 | status=500, 66 | ) 67 | responses.add( 68 | responses.POST, 69 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 70 | json={ 71 | "_id": "variable_id", 72 | "key": "variable-key", 73 | "type": "variable-type", 74 | "value": "hello world", 75 | }, 76 | ) 77 | result = self.test_client.variable("variable-key", self.test_user) 78 | self.assertEqual( 79 | result, 80 | Variable( 81 | _id="variable_id", 82 | key="variable-key", 83 | type="variable-type", 84 | value="hello world", 85 | ), 86 | ) 87 | 88 | @responses.activate(registry=OrderedRegistry) 89 | def test_variable_retries_network_error(self): 90 | responses.add( 91 | responses.POST, 92 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 93 | body=requests.exceptions.ConnectionError("Network Error"), 94 | ) 95 | responses.add( 96 | responses.POST, 97 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 98 | json={ 99 | "_id": "variable_id", 100 | "key": "variable-key", 101 | "type": "variable-type", 102 | "value": "hello world", 103 | }, 104 | ) 105 | result = self.test_client.variable("variable-key", self.test_user) 106 | self.assertEqual( 107 | result, 108 | Variable( 109 | _id="variable_id", 110 | key="variable-key", 111 | type="variable-type", 112 | value="hello world", 113 | ), 114 | ) 115 | 116 | @responses.activate 117 | def test_variable_retries_exceeded(self): 118 | for i in range(self.test_client.options.request_retries + 1): 119 | responses.add( 120 | responses.POST, 121 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 122 | status=500, 123 | ) 124 | with self.assertRaises(CloudClientError): 125 | self.test_client.variable("variable-key", self.test_user) 126 | 127 | @responses.activate 128 | def test_variable_unauthorized(self): 129 | for i in range(2): 130 | responses.add( 131 | responses.POST, 132 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 133 | status=401, 134 | ) 135 | with self.assertRaises(CloudClientUnauthorizedError): 136 | self.test_client.variable("variable-key", self.test_user) 137 | 138 | @responses.activate 139 | def test_variable_not_found(self): 140 | for i in range(2): 141 | responses.add( 142 | responses.POST, 143 | "https://bucketing-api.devcycle.com/v1/variables/variable-key", 144 | status=404, 145 | ) 146 | with self.assertRaises(NotFoundError): 147 | self.test_client.variable("variable-key", self.test_user) 148 | 149 | @responses.activate 150 | def test_variables(self): 151 | responses.add( 152 | responses.POST, 153 | "https://bucketing-api.devcycle.com/v1/variables", 154 | json={ 155 | "variable-1": { 156 | "_id": "variable_id", 157 | "key": "variable-key", 158 | "type": "variable-type", 159 | "value": "hello world", 160 | }, 161 | }, 162 | ) 163 | result = self.test_client.variables(self.test_user) 164 | 165 | self.assertEqual( 166 | result, 167 | { 168 | "variable-1": Variable( 169 | _id="variable_id", 170 | key="variable-key", 171 | type="variable-type", 172 | value="hello world", 173 | isDefaulted=None, 174 | ) 175 | }, 176 | ) 177 | 178 | @responses.activate 179 | def test_features(self): 180 | responses.add( 181 | responses.POST, 182 | "https://bucketing-api.devcycle.com/v1/features", 183 | json={ 184 | "feature-1": { 185 | "_id": "variable_id", 186 | "key": "variable-key", 187 | "type": "feature-type", 188 | "_variation": "variation", 189 | "variationName": "variation-name", 190 | "variationKey": "variation-key", 191 | "evalReason": "eval-reason", 192 | }, 193 | }, 194 | ) 195 | result = self.test_client.features(self.test_user) 196 | 197 | self.assertEqual( 198 | result, 199 | { 200 | "feature-1": Feature( 201 | _id="variable_id", 202 | key="variable-key", 203 | type="feature-type", 204 | _variation="variation", 205 | variationName="variation-name", 206 | variationKey="variation-key", 207 | evalReason="eval-reason", 208 | ) 209 | }, 210 | ) 211 | 212 | @responses.activate 213 | def test_track(self): 214 | responses.add( 215 | responses.POST, 216 | "https://bucketing-api.devcycle.com/v1/track", 217 | json={ 218 | "message": "success", 219 | }, 220 | ) 221 | result = self.test_client.track( 222 | self.test_user, 223 | events=[ 224 | DevCycleEvent( 225 | type="sample-event", 226 | ) 227 | ], 228 | ) 229 | self.assertEqual(result, "success") 230 | data = json.loads(responses.calls[0].request.body) 231 | self.assertTrue(isinstance(data["events"][0]["date"], int)) 232 | self.assertEqual(len(data["events"]), 1) 233 | self.assertEqual(data["events"][0]["type"], "sample-event") 234 | 235 | 236 | if __name__ == "__main__": 237 | unittest.main() 238 | -------------------------------------------------------------------------------- /test/api/test_config_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import uuid 4 | from http import HTTPStatus 5 | from datetime import datetime 6 | from email.utils import formatdate 7 | from time import mktime 8 | 9 | import requests 10 | import responses 11 | from responses.registries import OrderedRegistry 12 | 13 | from devcycle_python_sdk.api.config_client import ConfigAPIClient 14 | from devcycle_python_sdk.options import DevCycleLocalOptions 15 | from devcycle_python_sdk.exceptions import ( 16 | APIClientError, 17 | APIClientUnauthorizedError, 18 | NotFoundError, 19 | ) 20 | from devcycle_python_sdk.util.strings import slash_join 21 | from test.fixture.data import small_config_json 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class ConfigAPIClientTest(unittest.TestCase): 27 | def setUp(self) -> None: 28 | self.sdk_key = "dvc_server_" + str(uuid.uuid4()) 29 | self.config_url = ( 30 | slash_join( 31 | "https://config-cdn.devcycle.com/", 32 | "config", 33 | "v2", 34 | "server", 35 | self.sdk_key, 36 | ) 37 | + ".json" 38 | ) 39 | 40 | options = DevCycleLocalOptions(config_retry_delay_ms=0) 41 | self.test_client = ConfigAPIClient(self.sdk_key, options) 42 | now = datetime.now() 43 | stamp = mktime(now.timetuple()) 44 | self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True) 45 | self.test_etag = str(uuid.uuid4()) 46 | self.test_config_json: dict = small_config_json() 47 | 48 | def test_url(self): 49 | self.assertEqual(self.test_client.config_file_url, self.config_url) 50 | 51 | @responses.activate 52 | def test_get_config(self): 53 | new_etag = str(uuid.uuid4()) 54 | now = datetime.now() 55 | stamp = mktime(now.timetuple()) 56 | new_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True) 57 | responses.add( 58 | responses.GET, 59 | self.config_url, 60 | headers={"ETag": new_etag, "Last-Modified": new_lastmodified}, 61 | json=self.test_config_json, 62 | ) 63 | result, etag, lastmodified = self.test_client.get_config( 64 | config_etag=self.test_etag, last_modified=self.test_lastmodified 65 | ) 66 | self.assertDictEqual(result, self.test_config_json) 67 | self.assertEqual(lastmodified, new_lastmodified) 68 | self.assertEqual(etag, new_etag) 69 | 70 | @responses.activate(registry=OrderedRegistry) 71 | def test_get_config_retries(self): 72 | responses.add( 73 | responses.GET, 74 | self.config_url, 75 | status=500, 76 | ) 77 | responses.add( 78 | responses.GET, 79 | self.config_url, 80 | headers={"ETag": self.test_etag}, 81 | json=self.test_config_json, 82 | ) 83 | result, etag, lastmodified = self.test_client.get_config( 84 | config_etag=self.test_etag 85 | ) 86 | 87 | self.assertDictEqual(result, self.test_config_json) 88 | self.assertEqual(etag, self.test_etag) 89 | 90 | @responses.activate(registry=OrderedRegistry) 91 | def test_get_config_retries_network_error(self): 92 | responses.add( 93 | responses.GET, 94 | self.config_url, 95 | body=requests.exceptions.ConnectionError("Network Error"), 96 | ) 97 | responses.add( 98 | responses.GET, 99 | self.config_url, 100 | headers={"ETag": self.test_etag, "Last-Modified": self.test_lastmodified}, 101 | json=self.test_config_json, 102 | ) 103 | result, etag, last_modified = self.test_client.get_config( 104 | config_etag=self.test_etag 105 | ) 106 | self.assertDictEqual(result, self.test_config_json) 107 | self.assertEqual(etag, self.test_etag) 108 | 109 | @responses.activate(registry=OrderedRegistry) 110 | def test_get_config_retries_exceeded(self): 111 | for i in range(2): 112 | responses.add( 113 | responses.GET, 114 | self.config_url, 115 | status=HTTPStatus.INTERNAL_SERVER_ERROR, 116 | ) 117 | with self.assertRaises(APIClientError): 118 | self.test_client.get_config(config_etag=self.test_etag) 119 | 120 | @responses.activate 121 | def test_get_config_not_found(self): 122 | for i in range(2): 123 | responses.add( 124 | responses.GET, 125 | self.config_url, 126 | status=HTTPStatus.NOT_FOUND, 127 | ) 128 | with self.assertRaises(NotFoundError): 129 | self.test_client.get_config(config_etag=self.test_etag) 130 | 131 | @responses.activate 132 | def test_get_config_unauthorized(self): 133 | for i in range(2): 134 | responses.add( 135 | responses.GET, 136 | self.config_url, 137 | status=HTTPStatus.UNAUTHORIZED, 138 | ) 139 | with self.assertRaises(APIClientUnauthorizedError): 140 | self.test_client.get_config(config_etag=self.test_etag) 141 | 142 | @responses.activate 143 | def test_get_config_not_modified(self): 144 | responses.add( 145 | responses.GET, 146 | self.config_url, 147 | status=HTTPStatus.NOT_MODIFIED, 148 | ) 149 | 150 | new_config, new_etag, last_modified = self.test_client.get_config( 151 | config_etag=self.test_etag 152 | ) 153 | self.assertIsNone(new_config) 154 | self.assertEqual(new_etag, self.test_etag) 155 | -------------------------------------------------------------------------------- /test/api/test_event_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import uuid 4 | from http import HTTPStatus 5 | 6 | import responses 7 | from responses.registries import OrderedRegistry 8 | 9 | from devcycle_python_sdk.api.event_client import EventAPIClient 10 | from devcycle_python_sdk.options import DevCycleLocalOptions 11 | from devcycle_python_sdk.exceptions import ( 12 | APIClientError, 13 | APIClientUnauthorizedError, 14 | NotFoundError, 15 | ) 16 | from devcycle_python_sdk.models.event import ( 17 | UserEventsBatchRecord, 18 | RequestEvent, 19 | EventType, 20 | ) 21 | from devcycle_python_sdk.models.user import DevCycleUser 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class EventAPIClientTest(unittest.TestCase): 27 | def setUp(self) -> None: 28 | self.sdk_key = "dvc_server_" + str(uuid.uuid4()) 29 | options = DevCycleLocalOptions(events_api_uri="http://localhost:8080") 30 | 31 | self.test_client = EventAPIClient(self.sdk_key, options) 32 | self.test_batch_url = "http://localhost:8080/v1/events/batch" 33 | 34 | self.test_user = DevCycleUser(user_id="123") 35 | events = [ 36 | RequestEvent( 37 | type=EventType.VariableDefaulted, 38 | user_id="123", 39 | date="2023-06-27T12:50:19.871Z", 40 | clientDate="2023-06-27T12:50:19.871Z", 41 | ) 42 | ] 43 | self.test_batch = [UserEventsBatchRecord(user=self.test_user, events=events)] 44 | 45 | def test_url(self): 46 | self.assertEqual(self.test_client.batch_url, self.test_batch_url) 47 | 48 | @responses.activate 49 | def test_publish_events(self): 50 | responses.add( 51 | responses.POST, 52 | self.test_batch_url, 53 | json={"message": "Successfully received 1 event batches."}, 54 | ) 55 | 56 | message = self.test_client.publish_events(self.test_batch) 57 | self.assertEqual(message, "Successfully received 1 event batches.") 58 | 59 | @responses.activate 60 | def test_publish_events_empty_batch(self): 61 | responses.add( 62 | responses.POST, 63 | self.test_batch_url, 64 | json={"message": "Successfully received 1 event batches."}, 65 | ) 66 | message = self.test_client.publish_events([]) 67 | self.assertEqual(message, "Successfully received 1 event batches.") 68 | 69 | @responses.activate(registry=OrderedRegistry) 70 | def test_publish_events_retries_exceeded(self): 71 | for i in range(2): 72 | responses.add( 73 | responses.POST, 74 | self.test_batch_url, 75 | status=HTTPStatus.INTERNAL_SERVER_ERROR, 76 | ) 77 | with self.assertRaises(APIClientError): 78 | self.test_client.publish_events(self.test_batch) 79 | 80 | @responses.activate 81 | def test_publish_events_not_found(self): 82 | for i in range(2): 83 | responses.add( 84 | responses.POST, 85 | self.test_batch_url, 86 | status=HTTPStatus.NOT_FOUND, 87 | ) 88 | with self.assertRaises(NotFoundError): 89 | self.test_client.publish_events(self.test_batch) 90 | 91 | @responses.activate 92 | def test_publish_events_unauthorized(self): 93 | for i in range(2): 94 | responses.add( 95 | responses.POST, 96 | self.test_batch_url, 97 | status=HTTPStatus.UNAUTHORIZED, 98 | ) 99 | with self.assertRaises(APIClientUnauthorizedError): 100 | self.test_client.publish_events(self.test_batch) 101 | -------------------------------------------------------------------------------- /test/fixture/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/fixture/__init__.py -------------------------------------------------------------------------------- /test/fixture/data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | 5 | def small_config() -> str: 6 | config_filename = os.path.join( 7 | os.path.dirname(__file__), "fixture_small_config.json" 8 | ) 9 | with open(config_filename, "r", encoding="utf-8") as f: 10 | return f.read() 11 | 12 | 13 | def small_config_json() -> dict: 14 | data = small_config() 15 | return json.loads(data) 16 | 17 | 18 | def special_character_config() -> str: 19 | config_filename = os.path.join( 20 | os.path.dirname(__file__), "fixture_small_config_special_characters.json" 21 | ) 22 | with open(config_filename, "r", encoding="utf-8") as f: 23 | return f.read() 24 | 25 | 26 | def special_character_config_json() -> dict: 27 | data = special_character_config() 28 | return json.loads(data) 29 | 30 | 31 | def large_config() -> str: 32 | config_filename = os.path.join( 33 | os.path.dirname(__file__), "fixture_large_config.json" 34 | ) 35 | with open(config_filename, "r", encoding="utf-8") as f: 36 | return f.read() 37 | 38 | 39 | def large_config_json() -> dict: 40 | data = large_config() 41 | return json.loads(data) 42 | 43 | 44 | def bucketed_config() -> str: 45 | config_filename = os.path.join( 46 | os.path.dirname(__file__), "fixture_bucketed_config.json" 47 | ) 48 | with open(config_filename, "r", encoding="utf-8") as f: 49 | return f.read() 50 | 51 | 52 | def bucketed_config_minimal() -> str: 53 | config_filename = os.path.join( 54 | os.path.dirname(__file__), "fixture_bucketed_config_minimal.json" 55 | ) 56 | with open(config_filename, "r", encoding="utf-8") as f: 57 | return f.read() 58 | -------------------------------------------------------------------------------- /test/fixture/fixture_bucketed_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": { 3 | "_id": "638680c459f1b81cc9e6c557", 4 | "key": "test-harness-data", 5 | "a0_organization": "org_fakeorg", 6 | "settings": { 7 | "edgeDB": { 8 | "enabled": false 9 | }, 10 | "optIn": { 11 | "enabled": false, 12 | "colors": { 13 | "primary": "#000000", 14 | "secondary": "#000000" 15 | }, 16 | "title": "title", 17 | "description": "description", 18 | "image_url": "image_url" 19 | } 20 | } 21 | }, 22 | "environment": { 23 | "_id": "638680c459f1b81cc9e6c559", 24 | "key": "development" 25 | }, 26 | "features": { 27 | "test-harness": { 28 | "_id": "638680d6fcb67b96878d90e6", 29 | "type": "release", 30 | "key": "test-harness", 31 | "_variation": "638680d6fcb67b96878d90ec", 32 | "variationName": "Variation On", 33 | "variationKey": "variation-on" 34 | }, 35 | "schedule-feature": { 36 | "_id": "6386813a59f1b81cc9e6c68d", 37 | "type": "release", 38 | "key": "schedule-feature", 39 | "_variation": "6386813a59f1b81cc9e6c693", 40 | "variationName": "Variation On", 41 | "variationKey": "variation-on" 42 | } 43 | }, 44 | "featureVariationMap": { 45 | "638680d6fcb67b96878d90e6": "638680d6fcb67b96878d90ec", 46 | "6386813a59f1b81cc9e6c68d": "6386813a59f1b81cc9e6c693" 47 | }, 48 | "variableVariationMap": { 49 | "bool-var": { 50 | "_feature": "638680d6fcb67b96878d90e6", 51 | "_variation": "638680d6fcb67b96878d90ec" 52 | }, 53 | "string-var": { 54 | "_feature": "638680d6fcb67b96878d90e6", 55 | "_variation": "638680d6fcb67b96878d90ec" 56 | }, 57 | "number-var": { 58 | "_feature": "638680d6fcb67b96878d90e6", 59 | "_variation": "638680d6fcb67b96878d90ec" 60 | }, 61 | "json-var": { 62 | "_feature": "638680d6fcb67b96878d90e6", 63 | "_variation": "638680d6fcb67b96878d90ec" 64 | }, 65 | "unicode-var": { 66 | "_feature": "638680d6fcb67b96878d90e6", 67 | "_variation": "638680d6fcb67b96878d90ec" 68 | }, 69 | "schedule-feature": { 70 | "_feature": "6386813a59f1b81cc9e6c68d", 71 | "_variation": "6386813a59f1b81cc9e6c693" 72 | } 73 | }, 74 | "variables": { 75 | "bool-var": { 76 | "_id": "638681f059f1b81cc9e6c7fa", 77 | "type": "Boolean", 78 | "key": "bool-var", 79 | "value": true, 80 | "isDefaulted": false, 81 | "defaultValue": false, 82 | "evalReason": "evalReason" 83 | }, 84 | "string-var": { 85 | "_id": "638681f059f1b81cc9e6c7fb", 86 | "type": "String", 87 | "key": "string-var", 88 | "value": "string", 89 | "isDefaulted": false, 90 | "defaultValue": "default", 91 | "evalReason": "evalReason" 92 | }, 93 | "number-var": { 94 | "_id": "638681f059f1b81cc9e6c7fc", 95 | "type": "Number", 96 | "key": "number-var", 97 | "value": 1, 98 | "isDefaulted": false, 99 | "defaultValue": 0, 100 | "evalReason": "evalReason" 101 | }, 102 | "json-var": { 103 | "_id": "638681f059f1b81cc9e6c7fd", 104 | "type": "JSON", 105 | "key": "json-var", 106 | "value": { 107 | "facts": true 108 | }, 109 | "isDefaulted": false, 110 | "defaultValue": {}, 111 | "evalReason": "evalReason" 112 | }, 113 | "unicode-var": { 114 | "_id": "638681f059f1b81cc9e6c7fe", 115 | "type": "String", 116 | "key": "unicode-var", 117 | "value": "↑↑↓↓←→←→BA 🤖" 118 | }, 119 | "schedule-feature": { 120 | "_id": "6386813a59f1b81cc9e6c68f", 121 | "type": "Boolean", 122 | "key": "schedule-feature", 123 | "value": true 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /test/fixture/fixture_bucketed_config_minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": { 3 | "_id": "638680c459f1b81cc9e6c557", 4 | "key": "test-harness-data", 5 | "a0_organization": "org_fakeorg" 6 | }, 7 | "environment": { 8 | "_id": "638680c459f1b81cc9e6c559", 9 | "key": "development" 10 | }, 11 | "features": {}, 12 | "featureVariationMap": {}, 13 | "variableVariationMap": {}, 14 | "variables": {} 15 | } -------------------------------------------------------------------------------- /test/fixture/fixture_small_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": { 3 | "_id": "61f97628ff4afcb6d057dbf0", 4 | "key": "emma-project", 5 | "a0_organization": "org_tPyJN5dvNNirKar7", 6 | "settings": { 7 | "edgeDB": { 8 | "enabled": false 9 | }, 10 | "optIn": { 11 | "enabled": true, 12 | "title": "EarlyAccess", 13 | "description": "Getearlyaccesstobetafeaturesbelow!", 14 | "imageURL": "", 15 | "colors": { 16 | "primary": "#531cd9", 17 | "secondary": "#16dec0" 18 | } 19 | }, 20 | "sdkTypeVisibility": { 21 | "enabledInFeatureSettings": true 22 | } 23 | } 24 | }, 25 | "environment": { 26 | "_id": "61f97628ff4afcb6d057dbf2", 27 | "key": "development" 28 | }, 29 | "features": [ 30 | { 31 | "_id": "62fbf6566f1ba302829f9e32", 32 | "key": "a-cool-new-feature", 33 | "type": "release", 34 | "variations": [ 35 | { 36 | "key": "variation-on", 37 | "name": "VariationOn", 38 | "variables": [ 39 | { 40 | "_var": "62fbf6566f1ba302829f9e34", 41 | "value": true 42 | }, 43 | { 44 | "_var": "63125320a4719939fd57cb2b", 45 | "value": "variationOn" 46 | }, 47 | { 48 | "_var": "64372363125123fca69d3f7b", 49 | "value": { 50 | "displayText": "This variation is on", 51 | "showDialog": true, 52 | "maxUsers": 100 53 | } 54 | }, 55 | { 56 | "_var": "65272363125123fca69d3a7d", 57 | "value": 12345 58 | }, 59 | { 60 | "_var": "61200363125123fca69d3a7a", 61 | "value": 3.14159 62 | } 63 | ], 64 | "_id": "62fbf6566f1ba302829f9e39" 65 | }, 66 | { 67 | "key": "variation-off", 68 | "name": "VariationOff", 69 | "variables": [ 70 | { 71 | "_var": "62fbf6566f1ba302829f9e34", 72 | "value": false 73 | }, 74 | { 75 | "_var": "63125320a4719939fd57cb2b", 76 | "value": "variationOff" 77 | }, 78 | { 79 | "_var": "64372363125123fca69d3f7b", 80 | "value": { 81 | "displayText": "This variation is off", 82 | "showDialog": false, 83 | "maxUsers": 0 84 | } 85 | }, 86 | { 87 | "_var": "65272363125123fca69d3a7d", 88 | "value": 67890 89 | }, 90 | { 91 | "_var": "61200363125123fca69d3a7a", 92 | "value": 0.0001 93 | } 94 | ], 95 | "_id": "62fbf6566f1ba302829f9e38" 96 | } 97 | ], 98 | "configuration": { 99 | "_id": "62fbf6576f1ba302829f9e4d", 100 | "targets": [ 101 | { 102 | "_audience": { 103 | "_id": "63125321d31c601f992288b6", 104 | "filters": { 105 | "filters": [ 106 | { 107 | "type": "user", 108 | "subType": "email", 109 | "comparator": "=", 110 | "values": [ 111 | "giveMeVariationOff@email.com" 112 | ], 113 | "filters": [] 114 | } 115 | ], 116 | "operator": "and" 117 | } 118 | }, 119 | "distribution": [ 120 | { 121 | "_variation": "62fbf6566f1ba302829f9e38", 122 | "percentage": 1 123 | } 124 | ], 125 | "_id": "63125321d31c601f992288bb" 126 | }, 127 | { 128 | "_audience": { 129 | "_id": "63125321d31c601f992288b7", 130 | "filters": { 131 | "filters": [ 132 | { 133 | "type": "all", 134 | "values": [], 135 | "filters": [] 136 | } 137 | ], 138 | "operator": "and" 139 | } 140 | }, 141 | "distribution": [ 142 | { 143 | "_variation": "62fbf6566f1ba302829f9e39", 144 | "percentage": 1 145 | } 146 | ], 147 | "_id": "63125321d31c601f992288bc" 148 | } 149 | ], 150 | "forcedUsers": {} 151 | } 152 | } 153 | ], 154 | "variables": [ 155 | { 156 | "_id": "62fbf6566f1ba302829f9e34", 157 | "key": "a-cool-new-feature", 158 | "type": "Boolean" 159 | }, 160 | { 161 | "_id": "63125320a4719939fd57cb2b", 162 | "key": "string-var", 163 | "type": "String" 164 | }, 165 | { 166 | "_id": "64372363125123fca69d3f7b", 167 | "key": "json-var", 168 | "type": "JSON" 169 | }, 170 | { 171 | "_id": "65272363125123fca69d3a7d", 172 | "key": "num-var", 173 | "type": "Number" 174 | }, 175 | { 176 | "_id": "61200363125123fca69d3a7a", 177 | "key": "float-var", 178 | "type": "Number" 179 | } 180 | ], 181 | "variableHashes": { 182 | "a-cool-new-feature": 1868656757, 183 | "string-var": 2413071944, 184 | "json-var": 2763925441, 185 | "num-var": 3071254410, 186 | "float-var": 843393717 187 | } 188 | } -------------------------------------------------------------------------------- /test/fixture/fixture_small_config_special_characters.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": { 3 | "_id": "61f97628ff4afcb6d057dbf0", 4 | "key": "emma-project", 5 | "a0_organization": "org_tPyJN5dvNNirKar7", 6 | "settings": { 7 | "edgeDB": { 8 | "enabled": false 9 | }, 10 | "optIn": { 11 | "enabled": true, 12 | "title": "EarlyAccess", 13 | "description": "Getearlyaccesstobetafeaturesbelow!", 14 | "imageURL": "", 15 | "colors": { 16 | "primary": "#531cd9", 17 | "secondary": "#16dec0" 18 | } 19 | } 20 | } 21 | }, 22 | "environment": { 23 | "_id": "61f97628ff4afcb6d057dbf2", 24 | "key": "development" 25 | }, 26 | "features": [ 27 | { 28 | "_id": "62fbf6566f1ba302829f9e32", 29 | "key": "a-cool-new-feature", 30 | "type": "release", 31 | "variations": [ 32 | { 33 | "key": "variation-on", 34 | "name": "VariationOn", 35 | "variables": [ 36 | { 37 | "_var": "62fbf6566f1ba302829f9e34", 38 | "value": true 39 | }, 40 | { 41 | "_var": "63125320a4719939fd57cb2b", 42 | "value": "öé \uD83D\uDC0D ¥ variationOn" 43 | }, 44 | { 45 | "_var": "64372363125123fca69d3f7b", 46 | "value": { 47 | "displayText": "This variation is on", 48 | "showDialog": true, 49 | "maxUsers": 100 50 | } 51 | }, 52 | { 53 | "_var": "65272363125123fca69d3a7d", 54 | "value": 12345 55 | } 56 | ], 57 | "_id": "62fbf6566f1ba302829f9e39" 58 | }, 59 | { 60 | "key": "variation-off", 61 | "name": "VariationOff", 62 | "variables": [ 63 | { 64 | "_var": "62fbf6566f1ba302829f9e34", 65 | "value": false 66 | }, 67 | { 68 | "_var": "63125320a4719939fd57cb2b", 69 | "value": "öé \uD83D\uDC0D ¥ variationOff" 70 | }, 71 | { 72 | "_var": "64372363125123fca69d3f7b", 73 | "value": { 74 | "displayText": "This variation is off", 75 | "showDialog": false, 76 | "maxUsers": 0 77 | } 78 | }, 79 | { 80 | "_var": "65272363125123fca69d3a7d", 81 | "value": 67890 82 | } 83 | ], 84 | "_id": "62fbf6566f1ba302829f9e38" 85 | } 86 | ], 87 | "configuration": { 88 | "_id": "62fbf6576f1ba302829f9e4d", 89 | "targets": [ 90 | { 91 | "_audience": { 92 | "_id": "63125321d31c601f992288b6", 93 | "filters": { 94 | "filters": [ 95 | { 96 | "type": "user", 97 | "subType": "email", 98 | "comparator": "=", 99 | "values": [ 100 | "giveMeVariationOff@email.com" 101 | ], 102 | "filters": [] 103 | } 104 | ], 105 | "operator": "and" 106 | } 107 | }, 108 | "distribution": [ 109 | { 110 | "_variation": "62fbf6566f1ba302829f9e38", 111 | "percentage": 1 112 | } 113 | ], 114 | "_id": "63125321d31c601f992288bb" 115 | }, 116 | { 117 | "_audience": { 118 | "_id": "63125321d31c601f992288b7", 119 | "filters": { 120 | "filters": [ 121 | { 122 | "type": "all", 123 | "values": [], 124 | "filters": [] 125 | } 126 | ], 127 | "operator": "and" 128 | } 129 | }, 130 | "distribution": [ 131 | { 132 | "_variation": "62fbf6566f1ba302829f9e39", 133 | "percentage": 1 134 | } 135 | ], 136 | "_id": "63125321d31c601f992288bc" 137 | } 138 | ], 139 | "forcedUsers": {} 140 | } 141 | } 142 | ], 143 | "variables": [ 144 | { 145 | "_id": "62fbf6566f1ba302829f9e34", 146 | "key": "a-cool-new-feature", 147 | "type": "Boolean" 148 | }, 149 | { 150 | "_id": "63125320a4719939fd57cb2b", 151 | "key": "string-var", 152 | "type": "String" 153 | }, 154 | { 155 | "_id": "64372363125123fca69d3f7b", 156 | "key": "json-var", 157 | "type": "JSON" 158 | }, 159 | { 160 | "_id": "65272363125123fca69d3a7d", 161 | "key": "num-var", 162 | "type": "Number" 163 | } 164 | ], 165 | "variableHashes": { 166 | "a-cool-new-feature": 1868656757, 167 | "string-var": 2413071944, 168 | "json-var": 2763925441, 169 | "num-var": 3071254410 170 | } 171 | } -------------------------------------------------------------------------------- /test/managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/managers/__init__.py -------------------------------------------------------------------------------- /test/managers/test_config_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | import unittest 5 | import uuid 6 | from datetime import datetime 7 | from email.utils import formatdate 8 | from time import mktime 9 | from unittest.mock import patch, MagicMock 10 | 11 | from devcycle_python_sdk import DevCycleLocalOptions 12 | from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager 13 | from test.fixture.data import small_config_json 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class EnvironmentConfigManagerTest(unittest.TestCase): 19 | def setUp(self) -> None: 20 | self.sdk_key = "dvc_server_" + str(uuid.uuid4()) 21 | self.test_local_bucketing = MagicMock() 22 | self.test_options = DevCycleLocalOptions( 23 | config_polling_interval_ms=500, disable_realtime_updates=True 24 | ) 25 | 26 | now = datetime.now() 27 | stamp = mktime(now.timetuple()) 28 | self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True) 29 | self.test_etag = str(uuid.uuid4()) 30 | self.test_config_json = small_config_json() 31 | self.test_config_string = json.dumps(self.test_config_json) 32 | 33 | @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config") 34 | def test_init(self, mock_get_config): 35 | mock_get_config.return_value = ( 36 | self.test_config_json, 37 | self.test_etag, 38 | self.test_lastmodified, 39 | ) 40 | config_manager = EnvironmentConfigManager( 41 | self.sdk_key, self.test_options, self.test_local_bucketing 42 | ) 43 | 44 | # sleep to allow polling thread to load the config 45 | time.sleep(0.1) 46 | mock_get_config.assert_called_once_with(config_etag=None, last_modified=None) 47 | 48 | self.assertTrue(config_manager._polling_enabled) 49 | self.assertTrue(config_manager.is_alive()) 50 | self.assertTrue(config_manager.daemon) 51 | self.assertEqual(config_manager._config_etag, self.test_etag) 52 | self.assertDictEqual(config_manager._config, self.test_config_json) 53 | self.test_local_bucketing.store_config.assert_called_once_with( 54 | self.test_config_string 55 | ) 56 | self.assertTrue(config_manager.is_initialized()) 57 | 58 | @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config") 59 | def test_init_with_client_callback(self, mock_get_config): 60 | mock_get_config.return_value = ( 61 | self.test_config_json, 62 | self.test_etag, 63 | self.test_lastmodified, 64 | ) 65 | 66 | mock_callback = MagicMock() 67 | 68 | self.test_options.on_client_initialized = mock_callback 69 | 70 | config_manager = EnvironmentConfigManager( 71 | self.sdk_key, self.test_options, self.test_local_bucketing 72 | ) 73 | time.sleep(0.1) 74 | mock_get_config.assert_called_once_with(config_etag=None, last_modified=None) 75 | self.assertEqual(config_manager._config_etag, self.test_etag) 76 | self.assertDictEqual(config_manager._config, self.test_config_json) 77 | self.test_local_bucketing.store_config.assert_called_once_with( 78 | self.test_config_string 79 | ) 80 | self.assertTrue(config_manager.is_initialized()) 81 | mock_callback.assert_called_once() 82 | 83 | @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config") 84 | def test_init_with_client_callback_with_error(self, mock_get_config): 85 | mock_get_config.return_value = ( 86 | self.test_config_json, 87 | self.test_etag, 88 | self.test_lastmodified, 89 | ) 90 | mock_callback = MagicMock() 91 | mock_callback.side_effect = Exception( 92 | "Badly written callback generates an exception" 93 | ) 94 | 95 | self.test_options.on_client_initialized = mock_callback 96 | 97 | config_manager = EnvironmentConfigManager( 98 | self.sdk_key, self.test_options, self.test_local_bucketing 99 | ) 100 | # the callback error should not negatively impact initialization of the config manager 101 | time.sleep(0.1) 102 | mock_get_config.assert_called_once_with(config_etag=None, last_modified=None) 103 | self.assertEqual(config_manager._config_etag, self.test_etag) 104 | self.assertDictEqual(config_manager._config, self.test_config_json) 105 | self.test_local_bucketing.store_config.assert_called_once_with( 106 | self.test_config_string 107 | ) 108 | self.assertTrue(config_manager.is_initialized()) 109 | mock_callback.assert_called_once() 110 | 111 | @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config") 112 | def test_close(self, mock_get_config): 113 | mock_get_config.return_value = (self.test_config_json, self.test_etag) 114 | self.test_options.config_polling_interval_ms = 500 115 | 116 | config_manager = EnvironmentConfigManager( 117 | self.sdk_key, self.test_options, self.test_local_bucketing 118 | ) 119 | 120 | # sleep to allow polling thread to load the config 121 | time.sleep(0.1) 122 | 123 | config_manager.close() 124 | self.assertFalse(config_manager._polling_enabled) 125 | 126 | @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config") 127 | def test_get_config_unchanged(self, mock_get_config): 128 | mock_get_config.return_value = ( 129 | self.test_config_json, 130 | self.test_etag, 131 | self.test_lastmodified, 132 | ) 133 | 134 | self.test_options.config_polling_interval_ms = 200 135 | config_manager = EnvironmentConfigManager( 136 | self.sdk_key, self.test_options, self.test_local_bucketing 137 | ) 138 | 139 | time.sleep(0.1) 140 | # stop the polling 141 | config_manager.close() 142 | 143 | self.test_local_bucketing.store_config.reset_mock() 144 | mock_get_config.return_value = (None, config_manager._config_etag, None) 145 | 146 | # trigger refresh of the config directly 147 | config_manager._get_config() 148 | 149 | # verify that the config was not updated 150 | self.assertEqual(config_manager._config_etag, self.test_etag) 151 | self.assertDictEqual(config_manager._config, self.test_config_json) 152 | self.test_local_bucketing.store_config.assert_not_called() 153 | 154 | 155 | if __name__ == "__main__": 156 | unittest.main() 157 | -------------------------------------------------------------------------------- /test/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/models/__init__.py -------------------------------------------------------------------------------- /test/models/test_bucketed_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import unittest 4 | 5 | from devcycle_python_sdk.models.bucketed_config import BucketedConfig 6 | from test.fixture.data import bucketed_config, bucketed_config_minimal 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class BucketedConfigTest(unittest.TestCase): 12 | def test_bucketed_config_all_fields(self) -> None: 13 | bucketed_config_parsed = json.loads(bucketed_config()) 14 | result = BucketedConfig.from_json(bucketed_config_parsed) 15 | 16 | # Fields are already checked in the local bucketing tests, no need to repeat here 17 | self.assertIsNotNone(result) 18 | 19 | def test_bucketed_config_minimal(self) -> None: 20 | bucketed_config_parsed = json.loads(bucketed_config_minimal()) 21 | result = BucketedConfig.from_json(bucketed_config_parsed) 22 | self.assertIsNotNone(result) 23 | 24 | 25 | if __name__ == "__main__": 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /test/models/test_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | from devcycle_python_sdk.models.user import DevCycleUser 5 | 6 | from openfeature.provider import EvaluationContext 7 | from openfeature.exception import TargetingKeyMissingError, InvalidContextError 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class DevCycleUserTest(unittest.TestCase): 14 | def test_create_user_from_context_no_context(self): 15 | with self.assertRaises(TargetingKeyMissingError): 16 | DevCycleUser.create_user_from_context(None) 17 | 18 | def test_create_user_from_context_no_user_id(self): 19 | with self.assertRaises(TargetingKeyMissingError): 20 | DevCycleUser.create_user_from_context( 21 | EvaluationContext(targeting_key=None, attributes={}) 22 | ) 23 | 24 | with self.assertRaises(TargetingKeyMissingError): 25 | DevCycleUser.create_user_from_context( 26 | EvaluationContext(targeting_key=None, attributes=None) 27 | ) 28 | 29 | with self.assertRaises(TargetingKeyMissingError): 30 | DevCycleUser.create_user_from_context( 31 | EvaluationContext(targeting_key=None, attributes={"user_id": None}) 32 | ) 33 | 34 | def test_create_user_from_context_only_user_id(self): 35 | test_user_id = "12345" 36 | context = EvaluationContext(targeting_key=test_user_id, attributes=None) 37 | user = DevCycleUser.create_user_from_context(context) 38 | self.assertIsNotNone(user) 39 | self.assertEqual(user.user_id, test_user_id) 40 | 41 | context = EvaluationContext( 42 | targeting_key=None, attributes={"user_id": test_user_id} 43 | ) 44 | user = DevCycleUser.create_user_from_context(context) 45 | self.assertIsNotNone(user) 46 | self.assertEqual(user.user_id, test_user_id) 47 | 48 | # ensure that targeting_key takes precedence over user_id 49 | context = EvaluationContext( 50 | targeting_key=test_user_id, attributes={"user_id": "99999"} 51 | ) 52 | user = DevCycleUser.create_user_from_context(context) 53 | self.assertIsNotNone(user) 54 | self.assertEqual(user.user_id, test_user_id) 55 | 56 | def test_create_user_from_context_with_attributes(self): 57 | test_user_id = "12345" 58 | context = EvaluationContext( 59 | targeting_key=test_user_id, 60 | attributes={ 61 | "user_id": "1234", 62 | "email": "someone@example.com", 63 | "name": "John Doe", 64 | "language": "en", 65 | "country": "US", 66 | "appVersion": "1.0.0", 67 | "appBuild": "1", 68 | "deviceModel": "iPhone X21", 69 | }, 70 | ) 71 | user = DevCycleUser.create_user_from_context(context) 72 | self.assertIsNotNone(user) 73 | self.assertEqual(user.user_id, test_user_id) 74 | self.assertEqual(user.email, context.attributes["email"]) 75 | self.assertEqual(user.name, context.attributes["name"]) 76 | self.assertEqual(user.language, context.attributes["language"]) 77 | self.assertEqual(user.country, context.attributes["country"]) 78 | self.assertEqual(user.appVersion, context.attributes["appVersion"]) 79 | self.assertEqual(user.appBuild, context.attributes["appBuild"]) 80 | self.assertEqual(user.deviceModel, context.attributes["deviceModel"]) 81 | 82 | def test_create_user_from_context_with_custom_data(self): 83 | test_user_id = "12345" 84 | context = EvaluationContext( 85 | targeting_key=test_user_id, 86 | attributes={ 87 | "customData": { 88 | "strValue": "hello", 89 | "intValue": 123, 90 | "floatValue": 3.1456, 91 | "boolValue": True, 92 | }, 93 | "privateCustomData": { 94 | "strValue": "world", 95 | "intValue": 789, 96 | "floatValue": 0.0001, 97 | "BoolValue": False, 98 | }, 99 | }, 100 | ) 101 | user = DevCycleUser.create_user_from_context(context) 102 | self.assertIsNotNone(user) 103 | self.assertEqual(user.user_id, test_user_id) 104 | 105 | self.assertEqual( 106 | user.customData, 107 | { 108 | "strValue": "hello", 109 | "intValue": 123, 110 | "floatValue": 3.1456, 111 | "boolValue": True, 112 | }, 113 | ) 114 | self.assertEqual( 115 | user.privateCustomData, 116 | { 117 | "strValue": "world", 118 | "intValue": 789, 119 | "floatValue": 0.0001, 120 | "BoolValue": False, 121 | }, 122 | ) 123 | 124 | def test_create_user_from_context_with_unknown_data_fields(self): 125 | test_user_id = "12345" 126 | context = EvaluationContext( 127 | targeting_key=test_user_id, 128 | attributes={ 129 | "strValue": "hello", 130 | "intValue": 123, 131 | "floatValue": 3.1456, 132 | "boolValue": False, 133 | }, 134 | ) 135 | user = DevCycleUser.create_user_from_context(context) 136 | self.assertIsNotNone(user) 137 | self.assertEqual(user.user_id, test_user_id) 138 | 139 | # the fields should get reassigned to custom_data 140 | self.assertEqual( 141 | user.customData, 142 | { 143 | "strValue": "hello", 144 | "intValue": 123, 145 | "floatValue": 3.1456, 146 | "boolValue": False, 147 | }, 148 | ) 149 | self.assertEqual(user.privateCustomData, None) 150 | 151 | def test_set_custom_value(self): 152 | custom_data = {} 153 | with self.assertRaises(InvalidContextError): 154 | DevCycleUser._set_custom_value(custom_data, None, None) 155 | 156 | custom_data = {} 157 | DevCycleUser._set_custom_value(custom_data, "key1", None) 158 | self.assertDictEqual(custom_data, {"key1": None}) 159 | 160 | custom_data = {} 161 | DevCycleUser._set_custom_value(custom_data, "key1", "value1") 162 | self.assertDictEqual(custom_data, {"key1": "value1"}) 163 | 164 | custom_data = {} 165 | DevCycleUser._set_custom_value(custom_data, "key1", 1) 166 | self.assertDictEqual(custom_data, {"key1": 1}) 167 | 168 | custom_data = {} 169 | DevCycleUser._set_custom_value(custom_data, "key1", 3.1456) 170 | self.assertDictEqual(custom_data, {"key1": 3.1456}) 171 | 172 | custom_data = {} 173 | DevCycleUser._set_custom_value(custom_data, "key1", True) 174 | self.assertDictEqual(custom_data, {"key1": True}) 175 | 176 | custom_data = {} 177 | with self.assertRaises(InvalidContextError): 178 | DevCycleUser._set_custom_value(custom_data, "map_data", {"hello": "world"}) 179 | 180 | custom_data = {} 181 | with self.assertRaises(InvalidContextError): 182 | DevCycleUser._set_custom_value( 183 | custom_data, "list_data", ["one", "two", "three"] 184 | ) 185 | 186 | 187 | if __name__ == "__main__": 188 | unittest.main() 189 | -------------------------------------------------------------------------------- /test/openfeature_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/openfeature_test/__init__.py -------------------------------------------------------------------------------- /test/openfeature_test/test_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | from unittest.mock import MagicMock 4 | 5 | from openfeature.provider import EvaluationContext 6 | from openfeature.flag_evaluation import Reason 7 | from openfeature.exception import ( 8 | ErrorCode, 9 | TargetingKeyMissingError, 10 | InvalidContextError, 11 | TypeMismatchError, 12 | ) 13 | 14 | from devcycle_python_sdk.models.variable import Variable, TypeEnum 15 | 16 | from devcycle_python_sdk.open_feature_provider.provider import ( 17 | DevCycleProvider, 18 | ) 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class DevCycleProviderTest(unittest.TestCase): 25 | def setUp(self): 26 | self.client = MagicMock() 27 | self.client.is_initialized.return_value = True 28 | self.provider = DevCycleProvider(self.client) 29 | 30 | def test_resolve_details_client_not_ready(self): 31 | self.client.is_initialized.return_value = False 32 | 33 | details = self.provider._resolve( 34 | "test-flag", False, EvaluationContext("test-user") 35 | ) 36 | 37 | self.assertIsNotNone(details) 38 | self.assertEqual(details.value, False) 39 | self.assertEqual(details.reason, Reason.ERROR) 40 | self.assertEqual(details.error_code, ErrorCode.PROVIDER_NOT_READY) 41 | 42 | def test_resolve_details_client_no_user_in_context(self): 43 | context = EvaluationContext(targeting_key=None, attributes={}) 44 | 45 | with self.assertRaises(TargetingKeyMissingError): 46 | self.provider._resolve("test-flag", False, context) 47 | 48 | def test_resolve_details_no_key(self): 49 | self.client.variable.side_effect = ValueError("test error") 50 | context = EvaluationContext(targeting_key="user-1234") 51 | with self.assertRaises(InvalidContextError): 52 | self.provider._resolve(None, False, context) 53 | 54 | def test_resolve_details_no_default_value(self): 55 | self.client.variable.side_effect = ValueError("test error") 56 | context = EvaluationContext(targeting_key="user-1234") 57 | with self.assertRaises(InvalidContextError): 58 | self.provider._resolve("test-flag", None, context) 59 | 60 | def test_resolve_details_client_returns_none(self): 61 | self.client.variable.return_value = None 62 | context = EvaluationContext(targeting_key="user-1234") 63 | 64 | details = self.provider._resolve("test-flag", False, context) 65 | 66 | self.assertIsNotNone(details) 67 | self.assertEqual(details.value, False) 68 | self.assertEqual(details.reason, Reason.DEFAULT) 69 | 70 | def test_resolve_details_client_returns_default_variable(self): 71 | self.client.variable.return_value = Variable.create_default_variable( 72 | key="test-flag", default_value=False 73 | ) 74 | context = EvaluationContext(targeting_key="user-1234") 75 | details = self.provider._resolve("test-flag", False, context) 76 | 77 | self.assertIsNotNone(details) 78 | self.assertEqual(details.value, False) 79 | self.assertEqual(details.reason, Reason.DEFAULT) 80 | 81 | def test_resolve_boolean_details(self): 82 | key = "test-flag" 83 | variable_value = True 84 | default_value = False 85 | 86 | self.client.variable.return_value = Variable( 87 | _id=None, 88 | value=variable_value, 89 | key=key, 90 | type=TypeEnum.BOOLEAN, 91 | isDefaulted=False, 92 | defaultValue=False, 93 | ) 94 | 95 | context = EvaluationContext(targeting_key="user-1234") 96 | details = self.provider.resolve_boolean_details(key, default_value, context) 97 | 98 | self.assertIsNotNone(details) 99 | self.assertEqual(details.value, variable_value) 100 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 101 | 102 | def test_resolve_string_details(self): 103 | key = "test-flag" 104 | variable_value = "some string" 105 | default_value = "default string" 106 | 107 | self.client.variable.return_value = Variable( 108 | _id=None, 109 | value=variable_value, 110 | key=key, 111 | type=TypeEnum.STRING, 112 | isDefaulted=False, 113 | defaultValue=False, 114 | ) 115 | 116 | context = EvaluationContext(targeting_key="user-1234") 117 | details = self.provider.resolve_string_details(key, default_value, context) 118 | 119 | self.assertIsNotNone(details) 120 | self.assertEqual(details.value, variable_value) 121 | self.assertIsInstance(details.value, str) 122 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 123 | 124 | def test_resolve_integer_details(self): 125 | key = "test-flag" 126 | variable_value = 12345 127 | default_value = 0 128 | 129 | self.client.variable.return_value = Variable( 130 | _id=None, 131 | value=variable_value, 132 | key=key, 133 | type=TypeEnum.STRING, 134 | isDefaulted=False, 135 | defaultValue=False, 136 | ) 137 | 138 | context = EvaluationContext(targeting_key="user-1234") 139 | details = self.provider.resolve_integer_details(key, default_value, context) 140 | 141 | self.assertIsNotNone(details) 142 | self.assertIsInstance(details.value, int) 143 | self.assertEqual(details.value, variable_value) 144 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 145 | 146 | def test_resolve_float_details(self): 147 | key = "test-flag" 148 | variable_value = 3.145 149 | default_value = 0.0 150 | 151 | self.client.variable.return_value = Variable( 152 | _id=None, 153 | value=variable_value, 154 | key=key, 155 | type=TypeEnum.STRING, 156 | isDefaulted=False, 157 | defaultValue=False, 158 | ) 159 | 160 | context = EvaluationContext(targeting_key="user-1234") 161 | details = self.provider.resolve_float_details(key, default_value, context) 162 | 163 | self.assertIsNotNone(details) 164 | self.assertIsInstance(details.value, float) 165 | self.assertEqual(details.value, variable_value) 166 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 167 | 168 | def test_resolve_object_details_verify_default_value(self): 169 | key = "test-flag" 170 | context = EvaluationContext(targeting_key="user-1234") 171 | 172 | # Only flat dictionaries are supported as objects 173 | # lists, primitives and nested objects are not supported 174 | with self.assertRaises(TypeMismatchError): 175 | self.provider.resolve_object_details(key, [], context) 176 | 177 | with self.assertRaises(TypeMismatchError): 178 | self.provider.resolve_object_details(key, 1234, context) 179 | 180 | with self.assertRaises(TypeMismatchError): 181 | self.provider.resolve_object_details(key, 3.14, context) 182 | 183 | with self.assertRaises(TypeMismatchError): 184 | self.provider.resolve_object_details(key, False, context) 185 | 186 | with self.assertRaises(TypeMismatchError): 187 | self.provider.resolve_object_details(key, "some string", context) 188 | 189 | # test dictionaries with nested objects 190 | with self.assertRaises(TypeMismatchError): 191 | self.provider.resolve_object_details( 192 | key, {"nestedMap": {"someProp": "some value"}}, context 193 | ) 194 | 195 | def test_resolve_object_details(self): 196 | key = "test-flag" 197 | variable_value = {"some": "value"} 198 | default_value = {} 199 | 200 | self.client.variable.return_value = Variable( 201 | _id=None, 202 | value=variable_value, 203 | key=key, 204 | type=TypeEnum.STRING, 205 | isDefaulted=False, 206 | defaultValue=False, 207 | ) 208 | 209 | context = EvaluationContext(targeting_key="user-1234") 210 | details = self.provider.resolve_object_details(key, default_value, context) 211 | 212 | self.assertIsNotNone(details) 213 | self.assertIsInstance(details.value, dict) 214 | self.assertDictEqual(details.value, variable_value) 215 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 216 | 217 | 218 | if __name__ == "__main__": 219 | unittest.main() 220 | -------------------------------------------------------------------------------- /test/openfeature_test/test_provider_local_sdk.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import unittest 4 | 5 | import responses 6 | 7 | from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions 8 | from test.fixture.data import small_config_json 9 | 10 | from openfeature.provider import EvaluationContext 11 | from openfeature.flag_evaluation import Reason 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class DevCycleProviderWithLocalSDKTest(unittest.TestCase): 17 | """ 18 | Tests the DevCycleProvider with actual data from the Local SDK client to confirm data translations work 19 | """ 20 | 21 | def setUp(self) -> None: 22 | self.sdk_key = "dvc_server_949e4962-c624-4d20-a1ea-7f2501b2ba79" 23 | self.test_config_json = small_config_json() 24 | self.test_etag = "2f71454e-3279-4ca7-a8e7-802ce97bef43" 25 | 26 | config_url = "http://localhost/config/v2/server/" + self.sdk_key + ".json" 27 | 28 | responses.add( 29 | responses.GET, 30 | config_url, 31 | headers={"ETag": self.test_etag}, 32 | json=self.test_config_json, 33 | status=200, 34 | ) 35 | 36 | self.options = DevCycleLocalOptions( 37 | config_polling_interval_ms=5000, 38 | config_cdn_uri="http://localhost/", 39 | disable_custom_event_logging=True, 40 | disable_automatic_event_logging=True, 41 | ) 42 | self.client = None 43 | 44 | def tearDown(self) -> None: 45 | if self.client: 46 | self.client.close() 47 | responses.reset() 48 | 49 | def setup_client(self): 50 | self.client = DevCycleLocalClient(self.sdk_key, self.options) 51 | while not self.client.is_initialized(): 52 | time.sleep(0.05) 53 | 54 | @responses.activate 55 | def test_resolve_boolean_details(self): 56 | self.setup_client() 57 | provider = self.client.get_openfeature_provider() 58 | 59 | key = "a-cool-new-feature" 60 | expected_value = True 61 | default_value = False 62 | 63 | context = EvaluationContext(targeting_key="1234") 64 | details = provider.resolve_boolean_details(key, default_value, context) 65 | 66 | self.assertIsNotNone(details) 67 | self.assertEqual(details.value, expected_value) 68 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 69 | 70 | @responses.activate 71 | def test_resolve_integer_details(self): 72 | self.setup_client() 73 | provider = self.client.get_openfeature_provider() 74 | 75 | key = "num-var" 76 | expected_value = 12345 77 | default_value = 0 78 | 79 | context = EvaluationContext(targeting_key="1234") 80 | details = provider.resolve_integer_details(key, default_value, context) 81 | 82 | self.assertIsNotNone(details) 83 | self.assertEqual(details.value, expected_value) 84 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 85 | 86 | @responses.activate 87 | def test_resolve_float_details(self): 88 | self.setup_client() 89 | provider = self.client.get_openfeature_provider() 90 | 91 | key = "float-var" 92 | expected_value = 3.14159 93 | default_value = 0.0001 94 | 95 | context = EvaluationContext(targeting_key="1234") 96 | details = provider.resolve_float_details(key, default_value, context) 97 | 98 | self.assertIsNotNone(details) 99 | self.assertEqual(details.value, expected_value) 100 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 101 | 102 | @responses.activate 103 | def test_resolve_string_details(self): 104 | self.setup_client() 105 | provider = self.client.get_openfeature_provider() 106 | 107 | key = "string-var" 108 | expected_value = "variationOn" 109 | default_value = "default_value" 110 | 111 | context = EvaluationContext(targeting_key="1234") 112 | details = provider.resolve_string_details(key, default_value, context) 113 | 114 | self.assertIsNotNone(details) 115 | self.assertEqual(details.value, expected_value) 116 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 117 | 118 | @responses.activate 119 | def test_resolve_object_details(self): 120 | self.setup_client() 121 | provider = self.client.get_openfeature_provider() 122 | 123 | key = "json-var" 124 | expected_value = { 125 | "displayText": "This variation is on", 126 | "showDialog": True, 127 | "maxUsers": 100, 128 | } 129 | default_value = {"default": "value"} 130 | 131 | context = EvaluationContext(targeting_key="1234") 132 | details = provider.resolve_string_details(key, default_value, context) 133 | 134 | self.assertIsNotNone(details) 135 | self.assertEqual(details.value, expected_value) 136 | self.assertEqual(details.reason, Reason.TARGETING_MATCH) 137 | -------------------------------------------------------------------------------- /test/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevCycleHQ/python-server-sdk/df7cf40c21459074a8bb6b891fcc276cf78d07d9/test/util/__init__.py -------------------------------------------------------------------------------- /test/util/test_strings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import unittest 4 | 5 | from devcycle_python_sdk.util.strings import slash_join 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class StringsUtilTest(unittest.TestCase): 11 | def test_slash_join_no_args(self): 12 | result = slash_join() 13 | self.assertEqual(result, "") 14 | 15 | def test_slash_join_url_no_slashes(self): 16 | result = slash_join("http://example.com", "hello", "world") 17 | self.assertEqual(result, "http://example.com/hello/world") 18 | 19 | def test_slash_join_url_components_with_slashes(self): 20 | result = slash_join("http://example.com", "/hello", "world/") 21 | self.assertEqual(result, "http://example.com/hello/world") 22 | 23 | def test_slash_join_url_components_with_numbers(self): 24 | result = slash_join("http://example.com", "v1", "variable", 1234) 25 | self.assertEqual(result, "http://example.com/v1/variable/1234") 26 | -------------------------------------------------------------------------------- /test/util/test_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import unittest 4 | 5 | from devcycle_python_sdk.models.variable import TypeEnum 6 | from devcycle_python_sdk.models.user import DevCycleUser 7 | import devcycle_python_sdk.protobuf.utils as utils 8 | import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class VersionTest(unittest.TestCase): 14 | def test_create_nullable_double(self): 15 | result = utils.create_nullable_double(None) 16 | self.assertIsNotNone(result) 17 | self.assertTrue(result.isNull) 18 | 19 | result = utils.create_nullable_double(99.0) 20 | self.assertIsNotNone(result) 21 | self.assertFalse(result.isNull) 22 | self.assertEqual(result.value, 99.0) 23 | 24 | def test_create_nullable_string(self): 25 | result = utils.create_nullable_string(None) 26 | self.assertIsNotNone(result) 27 | self.assertTrue(result.isNull) 28 | 29 | result = utils.create_nullable_string("") 30 | self.assertIsNotNone(result) 31 | self.assertFalse(result.isNull) 32 | self.assertEqual(result.value, "") 33 | 34 | result = utils.create_nullable_string("test value") 35 | self.assertIsNotNone(result) 36 | self.assertFalse(result.isNull) 37 | self.assertEqual(result.value, "test value") 38 | 39 | def test_create_nullable_custom_data(self): 40 | result = utils.create_nullable_custom_data(None) 41 | self.assertIsNotNone(result) 42 | self.assertTrue(result.isNull) 43 | 44 | result = utils.create_nullable_custom_data(dict()) 45 | self.assertIsNotNone(result) 46 | self.assertTrue(result.isNull) 47 | 48 | result = utils.create_nullable_custom_data({"strProp": "test value"}) 49 | self.assertIsNotNone(result) 50 | self.assertFalse(result.isNull) 51 | self.assertEqual(result.value["strProp"].type, pb2.CustomDataType.Str) 52 | self.assertEqual(result.value["strProp"].stringValue, "test value") 53 | 54 | result = utils.create_nullable_custom_data({"boolProp": False}) 55 | self.assertIsNotNone(result) 56 | self.assertFalse(result.isNull) 57 | self.assertEqual(result.value["boolProp"].type, pb2.CustomDataType.Bool) 58 | self.assertEqual(result.value["boolProp"].boolValue, False) 59 | 60 | result = utils.create_nullable_custom_data({"numProp": 1234.0}) 61 | self.assertIsNotNone(result) 62 | self.assertFalse(result.isNull) 63 | self.assertEqual(result.value["numProp"].type, pb2.CustomDataType.Num) 64 | self.assertEqual(result.value["numProp"].doubleValue, 1234.0) 65 | 66 | def test_create_variable_string(self): 67 | sdk_var = pb2.SDKVariable_PB( 68 | _id="test id", 69 | key="test key", 70 | stringValue="test value", 71 | type=pb2.VariableType_PB.String, 72 | ) 73 | 74 | var = utils.create_variable(sdk_var, "default value") 75 | self.assertIsNotNone(var) 76 | self.assertEqual(var.type, TypeEnum.STRING) 77 | self.assertIsNone(var._id) 78 | self.assertEqual(var.key, sdk_var.key) 79 | self.assertEqual(var.value, sdk_var.stringValue) 80 | self.assertEqual(var.defaultValue, "default value") 81 | self.assertFalse(var.isDefaulted) 82 | 83 | def test_create_variable_boolean(self): 84 | sdk_var = pb2.SDKVariable_PB( 85 | _id="test id", 86 | key="test key", 87 | boolValue=True, 88 | type=pb2.VariableType_PB.Boolean, 89 | ) 90 | 91 | var = utils.create_variable(sdk_var, False) 92 | self.assertIsNotNone(var) 93 | self.assertEqual(var.type, TypeEnum.BOOLEAN) 94 | self.assertIsNone(var._id) 95 | self.assertEqual(var.key, sdk_var.key) 96 | self.assertEqual(var.value, sdk_var.boolValue) 97 | self.assertEqual(var.defaultValue, False) 98 | self.assertFalse(var.isDefaulted) 99 | 100 | def test_create_variable_number(self): 101 | sdk_var = pb2.SDKVariable_PB( 102 | _id="test id", 103 | key="test key", 104 | doubleValue=99.99, 105 | type=pb2.VariableType_PB.Number, 106 | ) 107 | 108 | var = utils.create_variable(sdk_var, 0) 109 | self.assertIsNotNone(var) 110 | self.assertEqual(var.type, TypeEnum.NUMBER) 111 | self.assertIsNone(var._id) 112 | self.assertEqual(var.key, sdk_var.key) 113 | self.assertEqual(var.value, sdk_var.doubleValue) 114 | self.assertEqual(var.defaultValue, 0) 115 | self.assertFalse(var.isDefaulted) 116 | 117 | def test_create_variable_json(self): 118 | sdk_var = pb2.SDKVariable_PB( 119 | _id="test id", 120 | key="test key", 121 | stringValue='{"strProp": "test value"}', 122 | type=pb2.VariableType_PB.JSON, 123 | ) 124 | 125 | var = utils.create_variable(sdk_var, {}) 126 | self.assertIsNotNone(var) 127 | self.assertEqual(var.type, TypeEnum.JSON) 128 | self.assertIsNone(var._id) 129 | self.assertEqual(var.key, sdk_var.key) 130 | self.assertDictEqual(var.value, {"strProp": "test value"}) 131 | self.assertDictEqual(var.defaultValue, {}) 132 | self.assertFalse(var.isDefaulted) 133 | 134 | def test_create_dvcuser_pb_bad_app_build(self): 135 | user = DevCycleUser( 136 | user_id="test id", 137 | appBuild=None, 138 | ) 139 | 140 | result = utils.create_dvcuser_pb(user) 141 | self.assertIsNotNone(result) 142 | self.assertEqual(result.user_id, user.user_id) 143 | self.assertTrue(result.appBuild.isNull) 144 | 145 | user = DevCycleUser( 146 | user_id="test id", 147 | appBuild="NotANumberAtAll", 148 | ) 149 | 150 | result = utils.create_dvcuser_pb(user) 151 | self.assertIsNotNone(result) 152 | self.assertEqual(result.user_id, user.user_id) 153 | self.assertTrue(result.appBuild.isNull) 154 | 155 | def test_create_dvcuser_pb(self): 156 | user = DevCycleUser( 157 | user_id="test id", 158 | name="test name", 159 | email="test email", 160 | customData={"a": "value1", "b": 0.0, "c": False}, 161 | privateCustomData={"x": "value2", "y": 1234.0, "z": False}, 162 | appBuild="1.17", 163 | appVersion="1.0.0", 164 | country="US", 165 | language=None, 166 | deviceModel="iPhone X", 167 | ) 168 | 169 | result = utils.create_dvcuser_pb(user) 170 | self.assertIsNotNone(result) 171 | self.assertEqual(result.user_id, user.user_id) 172 | self.assertEqual(result.name.value, user.name) 173 | self.assertEqual(result.email.value, user.email) 174 | self.assertTrue(result.language.isNull) 175 | self.assertEqual(result.appBuild.value, 1.17) 176 | 177 | self.assertEqual(result.customData.value["a"].type, pb2.CustomDataType.Str) 178 | self.assertEqual(result.customData.value["a"].stringValue, "value1") 179 | 180 | self.assertEqual( 181 | result.privateCustomData.value["x"].type, pb2.CustomDataType.Str 182 | ) 183 | self.assertEqual(result.privateCustomData.value["x"].stringValue, "value2") 184 | -------------------------------------------------------------------------------- /test/util/test_version.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import unittest 4 | 5 | from devcycle_python_sdk.util.version import sdk_version 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class VersionTest(unittest.TestCase): 11 | def test_sdk_version(self): 12 | version = sdk_version() 13 | self.assertRegex(version, r"^\d+\.\d+\.\d+$") 14 | 15 | 16 | if __name__ == "__main__": 17 | unittest.main() 18 | -------------------------------------------------------------------------------- /update_wasm_lib.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BUCKETING_LIB_VERSION="1.35.1" 4 | 5 | if [[ -n "$1" ]]; then 6 | BUCKETING_LIB_VERSION="$1" 7 | fi 8 | 9 | cd devcycle_python_sdk 10 | rm bucketing-lib.release.wasm 11 | echo "Downloading bucketing lib version $BUCKETING_LIB_VERSION" 12 | wget "https://unpkg.com/@devcycle/bucketing-assembly-script@$BUCKETING_LIB_VERSION/build/bucketing-lib.release.wasm" 13 | --------------------------------------------------------------------------------