├── optimizely ├── py.typed ├── __init__.py ├── cmab │ ├── __init__.py │ └── cmab_service.py ├── event │ ├── __init__.py │ ├── log_event.py │ ├── user_event.py │ ├── payload.py │ ├── user_event_factory.py │ └── event_factory.py ├── lib │ └── __init__.py ├── odp │ ├── __init__.py │ ├── optimizely_odp_option.py │ ├── odp_event.py │ ├── odp_config.py │ ├── odp_segment_manager.py │ ├── odp_event_api_manager.py │ ├── lru_cache.py │ ├── odp_manager.py │ └── odp_segment_api_manager.py ├── decision │ ├── __init__.py │ ├── optimizely_decision_message.py │ ├── optimizely_decide_option.py │ └── optimizely_decision.py ├── helpers │ ├── __init__.py │ ├── experiment.py │ ├── sdk_settings.py │ ├── types.py │ ├── audience.py │ ├── condition_tree_evaluator.py │ ├── event_tag_utils.py │ └── constants.py ├── version.py ├── error_handler.py ├── notification_center_registry.py ├── event_dispatcher.py ├── exceptions.py ├── logger.py ├── notification_center.py ├── entities.py └── user_profile.py ├── .coveralls.yml ├── .coveragerc ├── docs ├── source │ ├── contributing.rst │ ├── optimizely_config.rst │ ├── index.rst │ ├── api_reference.rst │ ├── config_manager.rst │ └── conf.py ├── optimizely.png ├── README.md ├── Makefile └── make.bat ├── requirements ├── docs.txt ├── test.txt ├── typing.txt └── core.txt ├── MANIFEST.in ├── .github ├── pull_request_template.rst └── workflows │ ├── source_clear_cron.yml │ ├── ticket_reference_check.yml │ ├── integration_test.yml │ └── python.yml ├── .flake8 ├── mypy.ini ├── .readthedocs.yml ├── .gitignore ├── tests ├── __init__.py ├── helpers_tests │ ├── __init__.py │ ├── test_experiment.py │ └── test_event_tag_utils.py ├── test_odp_config.py ├── test_event_dispatcher.py ├── test_notification_center_registry.py ├── test_event_payload.py ├── test_user_profile.py ├── test_logger.py ├── test_lru_cache.py └── test_odp_event_api_manager.py ├── CODEOWNERS ├── setup.py └── CONTRIBUTING.md /optimizely/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | optimizely/lib/pymmh3* 4 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx==4.4.0 2 | sphinx-rtd-theme==1.2.2 3 | m2r==0.3.1 4 | -------------------------------------------------------------------------------- /docs/optimizely.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/optimizely/python-sdk/HEAD/docs/optimizely.png -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | flake8 >= 4.0.1 3 | funcsigs >= 0.4 4 | pytest >= 6.2.0 5 | pytest-cov 6 | python-coveralls -------------------------------------------------------------------------------- /requirements/typing.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | types-jsonschema 3 | types-requests 4 | types-Flask 5 | rpds-py<0.20.0; python_version < '3.11' -------------------------------------------------------------------------------- /docs/source/optimizely_config.rst: -------------------------------------------------------------------------------- 1 | OptimizelyConfig 2 | ================ 3 | 4 | .. automodule:: optimizely.optimizely_config 5 | :members: 6 | -------------------------------------------------------------------------------- /requirements/core.txt: -------------------------------------------------------------------------------- 1 | jsonschema>=3.2.0 2 | pyrsistent>=0.16.0 3 | requests>=2.21 4 | idna>=2.10 5 | rpds-py<0.20.0; python_version < '3.11' 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGELOG.md 3 | include README.md 4 | include requirements/* 5 | recursive-exclude docs * 6 | recursive-exclude tests * 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.rst: -------------------------------------------------------------------------------- 1 | Summary 2 | ------- 3 | 4 | - The “what”; a concise description of each logical change 5 | - Another change 6 | 7 | The “why”, or other context. 8 | 9 | Test plan 10 | --------- 11 | 12 | Issues 13 | ------ 14 | 15 | - “THING-1234” or “Fixes #123” 16 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../../README.md 2 | 3 | .. toctree:: 4 | :caption: API reference 5 | 6 | api_reference 7 | 8 | 9 | .. toctree:: 10 | :caption: Configuration Data 11 | 12 | config_manager 13 | optimizely_config 14 | 15 | 16 | .. toctree:: 17 | :caption: Help 18 | 19 | contributing 20 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E722 - do not use bare 'except' 3 | # W504 - Either W503 (Line break after Operand) or W503 ( 4 | # Line break before operand needs to be ignored for line lengths 5 | # greater than max-line-length. Best practice shows W504 6 | ignore = E722, W504 7 | exclude = optimizely/lib/pymmh3.py,*virtualenv*,tests/testapp/application.py 8 | max-line-length = 120 9 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # regex to exclude: 3 | # - docs folder 4 | # - setup.py 5 | # https://mypy.readthedocs.io/en/stable/config_file.html#confval-exclude 6 | exclude = (?x)( 7 | ^docs/ 8 | | ^setup\.py$ 9 | ) 10 | show_error_codes = True 11 | pretty = True 12 | 13 | # suppress error on conditional import of typing_extensions module 14 | [mypy-optimizely.helpers.types] 15 | no_warn_unused_ignores = True 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | python: 13 | version: 3.7 14 | install: 15 | - requirements: requirements/core.txt 16 | - requirements: requirements/docs.txt 17 | -------------------------------------------------------------------------------- /.github/workflows/source_clear_cron.yml: -------------------------------------------------------------------------------- 1 | name: Source clear 2 | 3 | on: 4 | schedule: 5 | # Runs "weekly" 6 | - cron: '0 0 * * 0' 7 | 8 | jobs: 9 | source_clear: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Source clear scan 14 | env: 15 | SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} 16 | run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s – scan 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | MANIFEST 3 | .idea/* 4 | .*virtualenv/* 5 | .mypy_cache 6 | .vscode/* 7 | 8 | # Output of building package 9 | *.egg-info 10 | dist 11 | build/* 12 | 13 | # Output of running coverage locally 14 | cover 15 | .coverage 16 | 17 | # From python-testapp.git 18 | .idea/ 19 | .virt/ 20 | datafile.json 21 | 22 | # vim swap files 23 | *.swp 24 | 25 | # OSX folder metadata 26 | *.DS_Store 27 | 28 | # Sphinx documentation 29 | docs/build/ 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/ticket_reference_check.yml: -------------------------------------------------------------------------------- 1 | name: Jira ticket reference check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, reopened, synchronize] 6 | 7 | jobs: 8 | 9 | jira_ticket_reference_check: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check for Jira ticket reference 14 | uses: optimizely/github-action-ticket-reference-checker-public@master 15 | with: 16 | bodyRegex: 'FSSDK-(?\d+)' 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | Getting Started 5 | --------------- 6 | 7 | ### Installing the requirements 8 | 9 | To install dependencies required to generate sphinx documentation locally, execute the following command from the main directory: 10 | 11 | pip install -r requirements/docs.txt 12 | 13 | ### Building documentation locally 14 | 15 | To generate Python SDK documentation locally, execute the following commands: 16 | 17 | cd docs/ 18 | make html 19 | 20 | This will build HTML docs in `docs/build/html/index.html`. Open this file in your web browser to see the docs. -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/cmab/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/event/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/odp/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/decision/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /optimizely/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /tests/helpers_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | -------------------------------------------------------------------------------- /docs/source/api_reference.rst: -------------------------------------------------------------------------------- 1 | Optimizely's APIs 2 | ================= 3 | 4 | .. autoclass:: optimizely.optimizely.Optimizely 5 | :members: 6 | :special-members: __init__ 7 | 8 | 9 | Event Dispatcher 10 | ================ 11 | .. autoclass:: optimizely.event_dispatcher.EventDispatcher 12 | :members: 13 | 14 | 15 | Logger 16 | ====== 17 | .. automodule:: optimizely.logger 18 | :members: 19 | 20 | 21 | User Profile 22 | ============ 23 | 24 | ``UserProfile`` 25 | --------------- 26 | 27 | .. autoclass:: optimizely.user_profile.UserProfile 28 | :members: 29 | 30 | ``UserProfileService`` 31 | ---------------------- 32 | 33 | .. autoclass:: optimizely.user_profile.UserProfileService 34 | :members: 35 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/config_manager.rst: -------------------------------------------------------------------------------- 1 | Config Manager 2 | ============== 3 | 4 | ``Base Config Manager`` 5 | ----------------------- 6 | 7 | .. autoclass:: optimizely.config_manager.BaseConfigManager 8 | :members: 9 | 10 | ``Static Config Manager`` 11 | ------------------------- 12 | 13 | .. autoclass:: optimizely.config_manager.StaticConfigManager 14 | :members: 15 | 16 | ``Polling Config Manager`` 17 | -------------------------- 18 | 19 | .. autoclass:: optimizely.config_manager.PollingConfigManager 20 | :members: 21 | 22 | ``Authenticated Datafile Polling Config Manager`` 23 | ------------------------------------------------- 24 | 25 | .. autoclass:: optimizely.config_manager.AuthDatafilePollingConfigManager 26 | :members: 27 | -------------------------------------------------------------------------------- /optimizely/version.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2020, 2022-2023, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | version_info = (5, 3, 0) 15 | __version__ = '.'.join(str(v) for v in version_info) 16 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | # Unless a later match takes precedence, @global-owner1 and @global-owner2 6 | # will be requested for review when someone opens a pull request. 7 | * @optimizely/fullstack-devs 8 | 9 | # Order is important; the last matching pattern takes the most precedence. 10 | # When someone opens a pull request that only modifies JS files, only @js-owner 11 | # and not the global owner(s) will be requested for a review. 12 | #*.js @js-owner 13 | 14 | # You can also use email addresses if you prefer. They'll be used to look up 15 | # users just like we do for commit author emails. 16 | #docs/* docs@example.com 17 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /optimizely/odp/optimizely_odp_option.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from sys import version_info 15 | 16 | if version_info < (3, 8): 17 | from typing_extensions import Final 18 | else: 19 | from typing import Final 20 | 21 | 22 | class OptimizelyOdpOption: 23 | """Options for the OdpSegmentManager.""" 24 | IGNORE_CACHE: Final = 'IGNORE_CACHE' 25 | RESET_CACHE: Final = 'RESET_CACHE' 26 | -------------------------------------------------------------------------------- /optimizely/decision/optimizely_decision_message.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from sys import version_info 15 | 16 | if version_info < (3, 8): 17 | from typing_extensions import Final 18 | else: 19 | from typing import Final 20 | 21 | 22 | class OptimizelyDecisionMessage: 23 | SDK_NOT_READY: Final = 'Optimizely SDK not configured properly yet.' 24 | FLAG_KEY_INVALID: Final = 'No flag was found for key "{}".' 25 | VARIABLE_VALUE_INVALID: Final = 'Variable value for key "{}" is invalid or wrong type.' 26 | -------------------------------------------------------------------------------- /optimizely/helpers/experiment.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | from __future__ import annotations 14 | from typing import TYPE_CHECKING 15 | 16 | if TYPE_CHECKING: 17 | # prevent circular dependenacy by skipping import at runtime 18 | from optimizely.entities import Experiment 19 | 20 | 21 | ALLOWED_EXPERIMENT_STATUS = ['Running'] 22 | 23 | 24 | def is_experiment_running(experiment: Experiment) -> bool: 25 | """ Determine for given experiment if experiment is running. 26 | 27 | Args: 28 | experiment: Object representing the experiment. 29 | 30 | Returns: 31 | Boolean representing if experiment is running or not. 32 | """ 33 | 34 | return experiment.status in ALLOWED_EXPERIMENT_STATUS 35 | -------------------------------------------------------------------------------- /optimizely/error_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | 15 | class BaseErrorHandler: 16 | """ Class encapsulating exception handling functionality. 17 | Override with your own exception handler providing handle_error method. """ 18 | 19 | @staticmethod 20 | def handle_error(error: Exception) -> None: 21 | pass 22 | 23 | 24 | class NoOpErrorHandler(BaseErrorHandler): 25 | """ Class providing handle_error method which suppresses the error. """ 26 | 27 | 28 | class RaiseExceptionErrorHandler(BaseErrorHandler): 29 | """ Class providing handle_error method which raises provided exception. """ 30 | 31 | @staticmethod 32 | def handle_error(error: Exception) -> None: 33 | raise error 34 | -------------------------------------------------------------------------------- /optimizely/decision/optimizely_decide_option.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from sys import version_info 15 | 16 | if version_info < (3, 8): 17 | from typing_extensions import Final 18 | else: 19 | from typing import Final 20 | 21 | 22 | class OptimizelyDecideOption: 23 | DISABLE_DECISION_EVENT: Final = 'DISABLE_DECISION_EVENT' 24 | ENABLED_FLAGS_ONLY: Final = 'ENABLED_FLAGS_ONLY' 25 | IGNORE_USER_PROFILE_SERVICE: Final = 'IGNORE_USER_PROFILE_SERVICE' 26 | INCLUDE_REASONS: Final = 'INCLUDE_REASONS' 27 | EXCLUDE_VARIABLES: Final = 'EXCLUDE_VARIABLES' 28 | IGNORE_CMAB_CACHE: Final = "IGNORE_CMAB_CACHE" 29 | RESET_CMAB_CACHE: Final = "RESET_CMAB_CACHE" 30 | INVALIDATE_USER_CMAB_CACHE: Final = "INVALIDATE_USER_CMAB_CACHE" 31 | -------------------------------------------------------------------------------- /optimizely/event/log_event.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import Optional, Any 16 | from sys import version_info 17 | from optimizely import event_builder 18 | 19 | 20 | if version_info < (3, 8): 21 | from typing_extensions import Literal 22 | else: 23 | from typing import Literal 24 | 25 | 26 | class LogEvent(event_builder.Event): 27 | """ Representation of an event which can be sent to Optimizely events API. """ 28 | 29 | def __init__( 30 | self, 31 | url: str, 32 | params: dict[str, Any], 33 | http_verb: Optional[Literal['POST', 'GET']] = None, 34 | headers: Optional[dict[str, str]] = None 35 | ): 36 | self.url = url 37 | self.params = params 38 | self.http_verb = http_verb or 'POST' 39 | self.headers = headers 40 | 41 | def __str__(self) -> str: 42 | return f'{self.__class__}: {self.__dict__}' 43 | -------------------------------------------------------------------------------- /tests/test_odp_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # https://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from tests import base 16 | from optimizely.odp.odp_config import OdpConfig 17 | 18 | 19 | class OdpConfigTest(base.BaseTest): 20 | api_host = 'test-host' 21 | api_key = 'test-key' 22 | segments_to_check = ['test-segment'] 23 | 24 | def test_init_config(self): 25 | config = OdpConfig(self.api_key, self.api_host, self.segments_to_check) 26 | 27 | self.assertEqual(config.get_api_key(), self.api_key) 28 | self.assertEqual(config.get_api_host(), self.api_host) 29 | self.assertEqual(config.get_segments_to_check(), self.segments_to_check) 30 | 31 | def test_update_config(self): 32 | config = OdpConfig() 33 | updated = config.update(self.api_key, self.api_host, self.segments_to_check) 34 | 35 | self.assertStrictTrue(updated) 36 | self.assertEqual(config.get_api_key(), self.api_key) 37 | self.assertEqual(config.get_api_host(), self.api_host) 38 | self.assertEqual(config.get_segments_to_check(), self.segments_to_check) 39 | 40 | updated = config.update(self.api_key, self.api_host, self.segments_to_check) 41 | self.assertStrictFalse(updated) 42 | -------------------------------------------------------------------------------- /tests/helpers_tests/test_experiment.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2017, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from unittest import mock 15 | 16 | from tests import base 17 | from optimizely import entities 18 | from optimizely.helpers import experiment 19 | 20 | 21 | class ExperimentTest(base.BaseTest): 22 | def test_is_experiment_running__status_running(self): 23 | """ Test that is_experiment_running returns True when experiment has Running status. """ 24 | 25 | self.assertTrue( 26 | experiment.is_experiment_running(self.project_config.get_experiment_from_key('test_experiment')) 27 | ) 28 | 29 | def test_is_experiment_running__status_not_running(self): 30 | """ Test that is_experiment_running returns False when experiment does not have running status. """ 31 | 32 | with mock.patch( 33 | 'optimizely.project_config.ProjectConfig.get_experiment_from_key', 34 | return_value=entities.Experiment('42', 'test_experiment', 'Some Status', [], [], {}, [], '43'), 35 | ) as mock_get_experiment: 36 | self.assertFalse( 37 | experiment.is_experiment_running(self.project_config.get_experiment_from_key('test_experiment')) 38 | ) 39 | mock_get_experiment.assert_called_once_with('test_experiment') 40 | -------------------------------------------------------------------------------- /.github/workflows/integration_test.yml: -------------------------------------------------------------------------------- 1 | name: Reusable action of running integration of production suite 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | FULLSTACK_TEST_REPO: 7 | required: false 8 | type: string 9 | secrets: 10 | CI_USER_TOKEN: 11 | required: true 12 | TRAVIS_COM_TOKEN: 13 | required: true 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | # You should create a personal access token and store it in your repository 21 | token: ${{ secrets.CI_USER_TOKEN }} 22 | repository: 'optimizely/travisci-tools' 23 | path: 'home/runner/travisci-tools' 24 | ref: 'master' 25 | - name: set SDK Branch if PR 26 | env: 27 | HEAD_REF: ${{ github.head_ref }} 28 | if: ${{ github.event_name == 'pull_request' }} 29 | run: | 30 | echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV 31 | - name: set SDK Branch if not pull request 32 | env: 33 | REF_NAME: ${{ github.ref_name }} 34 | if: ${{ github.event_name != 'pull_request' }} 35 | run: | 36 | echo "SDK_BRANCH=${REF_NAME}" >> $GITHUB_ENV 37 | echo "TRAVIS_BRANCH=${REF_NAME}" >> $GITHUB_ENV 38 | - name: Trigger build 39 | env: 40 | SDK: python 41 | FULLSTACK_TEST_REPO: ${{ inputs.FULLSTACK_TEST_REPO }} 42 | BUILD_NUMBER: ${{ github.run_id }} 43 | TESTAPP_BRANCH: master 44 | GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} 45 | EVENT_TYPE: ${{ github.event_name }} 46 | GITHUB_CONTEXT: ${{ toJson(github) }} 47 | #REPO_SLUG: ${{ github.repository }} 48 | PULL_REQUEST_SLUG: ${{ github.repository }} 49 | UPSTREAM_REPO: ${{ github.repository }} 50 | PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} 51 | PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} 52 | UPSTREAM_SHA: ${{ github.sha }} 53 | TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} 54 | EVENT_MESSAGE: ${{ github.event.message }} 55 | HOME: 'home/runner' 56 | run: | 57 | echo "$GITHUB_CONTEXT" 58 | home/runner/travisci-tools/trigger-script-with-status-update.sh 59 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../..')) 16 | 17 | from optimizely.version import __version__ # noqa: E402 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'Optimizely Python SDK' 22 | copyright = '2016-2020, Optimizely, Inc' 23 | author = 'Optimizely, Inc.' 24 | version = __version__ 25 | master_doc = 'index' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = '' 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "m2r", 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.napoleon", 39 | "sphinx.ext.autosectionlabel" 40 | ] 41 | autosectionlabel_prefix_document = True 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # List of patterns, relative to source directory, that match files and 47 | # directories to ignore when looking for source files. 48 | # This pattern also affects html_static_path and html_extra_path. 49 | exclude_patterns = [ 50 | ] 51 | 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | # html_theme = 'alabaster' 59 | html_theme = "sphinx_rtd_theme" 60 | # Add any paths that contain custom static files (such as style sheets) here, 61 | # relative to this directory. They are copied after the builtin static files, 62 | # so a file named "default.css" will overwrite the builtin "default.css". 63 | # html_static_path = ['_static'] 64 | html_logo = "../optimizely.png" 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | from setuptools import find_packages 5 | 6 | here = os.path.join(os.path.dirname(__file__)) 7 | 8 | 9 | __version__ = None 10 | with open(os.path.join(here, 'optimizely', 'version.py')) as _file: 11 | exec(_file.read()) 12 | 13 | with open(os.path.join(here, 'requirements', 'core.txt')) as _file: 14 | REQUIREMENTS = _file.read().splitlines() 15 | 16 | with open(os.path.join(here, 'requirements', 'test.txt')) as _file: 17 | TEST_REQUIREMENTS = _file.read().splitlines() 18 | TEST_REQUIREMENTS = list(set(REQUIREMENTS + TEST_REQUIREMENTS)) 19 | 20 | with open(os.path.join(here, 'README.md')) as _file: 21 | README = _file.read() 22 | 23 | with open(os.path.join(here, 'CHANGELOG.md')) as _file: 24 | CHANGELOG = _file.read() 25 | 26 | about_text = ( 27 | 'Optimizely Feature Experimentation is A/B testing and feature management for product development teams. ' 28 | 'Experiment in any application. Make every feature on your roadmap an opportunity to learn. ' 29 | 'Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at ' 30 | 'https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. ' 31 | ) 32 | 33 | setup( 34 | name='optimizely-sdk', 35 | version=__version__, 36 | description='Python SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), ' 37 | 'and Optimizely Rollouts.', 38 | long_description=about_text + README + CHANGELOG, 39 | long_description_content_type='text/markdown', 40 | author='Optimizely', 41 | author_email='developers@optimizely.com', 42 | url='https://github.com/optimizely/python-sdk', 43 | classifiers=[ 44 | 'Development Status :: 5 - Production/Stable', 45 | 'Environment :: Web Environment', 46 | 'Intended Audience :: Developers', 47 | 'License :: OSI Approved :: Apache Software License', 48 | 'Operating System :: OS Independent', 49 | 'Programming Language :: Python', 50 | 'Programming Language :: Python :: 3.8', 51 | 'Programming Language :: Python :: 3.9', 52 | 'Programming Language :: Python :: 3.10', 53 | 'Programming Language :: Python :: 3.11', 54 | 'Programming Language :: Python :: 3.12', 55 | ], 56 | packages=find_packages(exclude=['docs', 'tests']), 57 | extras_require={'test': TEST_REQUIREMENTS}, 58 | install_requires=REQUIREMENTS, 59 | tests_require=TEST_REQUIREMENTS, 60 | test_suite='tests', 61 | ) 62 | -------------------------------------------------------------------------------- /optimizely/notification_center_registry.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from threading import Lock 16 | from typing import Optional 17 | from .logger import Logger as OptimizelyLogger 18 | from .notification_center import NotificationCenter 19 | from .helpers.enums import Errors 20 | 21 | 22 | class _NotificationCenterRegistry: 23 | """ Class managing internal notification centers.""" 24 | _notification_centers: dict[str, NotificationCenter] = {} 25 | _lock = Lock() 26 | 27 | @classmethod 28 | def get_notification_center(cls, sdk_key: Optional[str], logger: OptimizelyLogger) -> Optional[NotificationCenter]: 29 | """Returns an internal notification center for the given sdk_key, creating one 30 | if none exists yet. 31 | 32 | Args: 33 | sdk_key: A string sdk key to uniquely identify the notification center. 34 | logger: Optional logger. 35 | 36 | Returns: 37 | None or NotificationCenter 38 | """ 39 | 40 | if not sdk_key: 41 | logger.error(f'{Errors.MISSING_SDK_KEY} ODP may not work properly without it.') 42 | return None 43 | 44 | with cls._lock: 45 | if sdk_key in cls._notification_centers: 46 | notification_center = cls._notification_centers[sdk_key] 47 | else: 48 | notification_center = NotificationCenter(logger) 49 | cls._notification_centers[sdk_key] = notification_center 50 | 51 | return notification_center 52 | 53 | @classmethod 54 | def remove_notification_center(cls, sdk_key: str) -> None: 55 | """Remove a previously added notification center and clear all its listeners. 56 | 57 | Args: 58 | sdk_key: The sdk_key of the notification center to remove. 59 | """ 60 | 61 | with cls._lock: 62 | notification_center = cls._notification_centers.pop(sdk_key, None) 63 | if notification_center: 64 | notification_center.clear_all_notification_listeners() 65 | -------------------------------------------------------------------------------- /optimizely/event_dispatcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import json 15 | import logging 16 | from sys import version_info 17 | 18 | import requests 19 | from requests import exceptions as request_exception 20 | from requests.adapters import HTTPAdapter 21 | from urllib3.util.retry import Retry 22 | 23 | from . import event_builder 24 | from .helpers.enums import HTTPVerbs, EventDispatchConfig 25 | 26 | if version_info < (3, 8): 27 | from typing_extensions import Protocol 28 | else: 29 | from typing import Protocol 30 | 31 | 32 | class CustomEventDispatcher(Protocol): 33 | """Interface for a custom event dispatcher and required method `dispatch_event`. """ 34 | 35 | def dispatch_event(self, event: event_builder.Event) -> None: 36 | ... 37 | 38 | 39 | class EventDispatcher: 40 | 41 | @staticmethod 42 | def dispatch_event(event: event_builder.Event) -> None: 43 | """ Dispatch the event being represented by the Event object. 44 | 45 | Args: 46 | event: Object holding information about the request to be dispatched to the Optimizely backend. 47 | """ 48 | try: 49 | session = requests.Session() 50 | 51 | retries = Retry(total=EventDispatchConfig.RETRIES, 52 | backoff_factor=0.1, 53 | status_forcelist=[500, 502, 503, 504]) 54 | adapter = HTTPAdapter(max_retries=retries) 55 | 56 | session.mount('http://', adapter) 57 | session.mount("https://", adapter) 58 | 59 | if event.http_verb == HTTPVerbs.GET: 60 | session.get(event.url, params=event.params, 61 | timeout=EventDispatchConfig.REQUEST_TIMEOUT).raise_for_status() 62 | elif event.http_verb == HTTPVerbs.POST: 63 | session.post( 64 | event.url, data=json.dumps(event.params), headers=event.headers, 65 | timeout=EventDispatchConfig.REQUEST_TIMEOUT, 66 | ).raise_for_status() 67 | 68 | except request_exception.RequestException as error: 69 | logging.error(f'Dispatch event failed. Error: {error}') 70 | -------------------------------------------------------------------------------- /optimizely/decision/optimizely_decision.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import Optional, Any, TYPE_CHECKING 16 | 17 | if TYPE_CHECKING: 18 | # prevent circular dependenacy by skipping import at runtime 19 | from optimizely.optimizely_user_context import OptimizelyUserContext 20 | 21 | 22 | class OptimizelyDecision: 23 | def __init__( 24 | self, 25 | variation_key: Optional[str] = None, 26 | enabled: bool = False, 27 | variables: Optional[dict[str, Any]] = None, 28 | rule_key: Optional[str] = None, 29 | flag_key: Optional[str] = None, 30 | user_context: Optional[OptimizelyUserContext] = None, 31 | reasons: Optional[list[str]] = None 32 | ): 33 | self.variation_key = variation_key 34 | self.enabled = enabled 35 | self.variables = variables or {} 36 | self.rule_key = rule_key 37 | self.flag_key = flag_key 38 | self.user_context = user_context 39 | self.reasons = reasons or [] 40 | 41 | def as_json(self) -> dict[str, Any]: 42 | return { 43 | 'variation_key': self.variation_key, 44 | 'enabled': self.enabled, 45 | 'variables': self.variables, 46 | 'rule_key': self.rule_key, 47 | 'flag_key': self.flag_key, 48 | 'user_context': self.user_context.as_json() if self.user_context else None, 49 | 'reasons': self.reasons 50 | } 51 | 52 | @classmethod 53 | def new_error_decision(cls, key: str, user: OptimizelyUserContext, reasons: list[str]) -> OptimizelyDecision: 54 | """Create a new OptimizelyDecision representing an error state. 55 | Args: 56 | key: The flag key 57 | user: The user context 58 | reasons: List of reasons explaining the error 59 | Returns: 60 | OptimizelyDecision with error state values 61 | """ 62 | return cls( 63 | variation_key=None, 64 | enabled=False, 65 | variables={}, 66 | rule_key=None, 67 | flag_key=key, 68 | user_context=user, 69 | reasons=reasons if reasons else [] 70 | ) 71 | -------------------------------------------------------------------------------- /optimizely/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2019, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | 15 | class InvalidAttributeException(Exception): 16 | """ Raised when provided attribute is invalid. """ 17 | 18 | pass 19 | 20 | 21 | class InvalidAudienceException(Exception): 22 | """ Raised when provided audience is invalid. """ 23 | 24 | pass 25 | 26 | 27 | class InvalidEventException(Exception): 28 | """ Raised when provided event key is invalid. """ 29 | 30 | pass 31 | 32 | 33 | class InvalidEventTagException(Exception): 34 | """ Raised when provided event tag is invalid. """ 35 | 36 | pass 37 | 38 | 39 | class InvalidExperimentException(Exception): 40 | """ Raised when provided experiment key is invalid. """ 41 | 42 | pass 43 | 44 | 45 | class InvalidGroupException(Exception): 46 | """ Raised when provided group ID is invalid. """ 47 | 48 | pass 49 | 50 | 51 | class InvalidInputException(Exception): 52 | """ Raised when provided datafile, event dispatcher, logger, event processor or error handler is invalid. """ 53 | 54 | pass 55 | 56 | 57 | class InvalidVariationException(Exception): 58 | """ Raised when provided variation is invalid. """ 59 | 60 | pass 61 | 62 | 63 | class UnsupportedDatafileVersionException(Exception): 64 | """ Raised when provided version in datafile is not supported. """ 65 | 66 | pass 67 | 68 | 69 | class OdpNotEnabled(Exception): 70 | """ Raised when Optimizely Data Platform (ODP) is not enabled. """ 71 | 72 | pass 73 | 74 | 75 | class OdpNotIntegrated(Exception): 76 | """ Raised when Optimizely Data Platform (ODP) is not integrated. """ 77 | 78 | pass 79 | 80 | 81 | class OdpInvalidData(Exception): 82 | """ Raised when passing invalid ODP data. """ 83 | 84 | pass 85 | 86 | 87 | class CmabError(Exception): 88 | """Base exception for CMAB client errors.""" 89 | 90 | pass 91 | 92 | 93 | class CmabFetchError(CmabError): 94 | """Exception raised when CMAB fetch fails.""" 95 | 96 | pass 97 | 98 | 99 | class CmabInvalidResponseError(CmabError): 100 | """Exception raised when CMAB response is invalid.""" 101 | 102 | pass 103 | -------------------------------------------------------------------------------- /optimizely/odp/odp_event.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | 16 | from typing import Any, Union, Dict 17 | import uuid 18 | import json 19 | from optimizely import version 20 | from optimizely.helpers.enums import OdpManagerConfig 21 | 22 | OdpDataDict = Dict[str, Union[str, int, float, bool, None]] 23 | 24 | 25 | class OdpEvent: 26 | """ Representation of an odp event which can be sent to the Optimizely odp platform. """ 27 | 28 | def __init__(self, type: str, action: str, identifiers: dict[str, str], data: OdpDataDict) -> None: 29 | self.type = type 30 | self.action = action 31 | self.identifiers = self._convert_identifers(identifiers) 32 | self.data = self._add_common_event_data(data) 33 | 34 | def __repr__(self) -> str: 35 | return str(self.__dict__) 36 | 37 | def __eq__(self, other: object) -> bool: 38 | if isinstance(other, OdpEvent): 39 | return self.__dict__ == other.__dict__ 40 | elif isinstance(other, dict): 41 | return self.__dict__ == other 42 | else: 43 | return False 44 | 45 | def _add_common_event_data(self, custom_data: OdpDataDict) -> OdpDataDict: 46 | data: OdpDataDict = { 47 | 'idempotence_id': str(uuid.uuid4()), 48 | 'data_source_type': 'sdk', 49 | 'data_source': 'python-sdk', 50 | 'data_source_version': version.__version__ 51 | } 52 | data.update(custom_data) 53 | return data 54 | 55 | def _convert_identifers(self, identifiers: dict[str, str]) -> dict[str, str]: 56 | """ 57 | Convert incorrect case/separator of identifier key `fs_user_id` 58 | (ie. `fs-user-id`, `FS_USER_ID`). 59 | """ 60 | for key in list(identifiers): 61 | if key == OdpManagerConfig.KEY_FOR_USER_ID: 62 | break 63 | elif key.lower() in ("fs-user-id", OdpManagerConfig.KEY_FOR_USER_ID): 64 | identifiers[OdpManagerConfig.KEY_FOR_USER_ID] = identifiers.pop(key) 65 | break 66 | 67 | return identifiers 68 | 69 | 70 | class OdpEventEncoder(json.JSONEncoder): 71 | def default(self, obj: object) -> Any: 72 | if isinstance(obj, OdpEvent): 73 | return obj.__dict__ 74 | return json.JSONEncoder.default(self, obj) 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We welcome contributions and feedback! All contributors must sign our 5 | [Contributor License Agreement 6 | (CLA)](https://docs.google.com/a/optimizely.com/forms/d/e/1FAIpQLSf9cbouWptIpMgukAKZZOIAhafvjFCV8hS00XJLWQnWDFtwtA/viewform) 7 | to be eligible to contribute. Please read the [README](README.md) to 8 | set up your development environment, then read the guidelines below for 9 | information on submitting your code. 10 | 11 | Development process 12 | ------------------- 13 | 14 | 1. Fork the repository and create your branch from master. 15 | 2. Please follow the [commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) 16 | for each commit message. 17 | 3. Make sure to add tests! 18 | 4. Run `flake8` to ensure there are no lint errors. 19 | 5. `git push` your changes to GitHub. 20 | 6. Open a PR from your fork into the master branch of the original 21 | repo. 22 | 7. Make sure that all unit tests are passing and that there are no 23 | merge conflicts between your branch and `master`. 24 | 8. Open a pull request from `YOUR_NAME/branch_name` to `master`. 25 | 9. A repository maintainer will review your pull request and, if all 26 | goes well, squash and merge it! 27 | 28 | Pull request acceptance criteria 29 | -------------------------------- 30 | 31 | - **All code must have test coverage.** We use unittest. Changes in 32 | functionality should have accompanying unit tests. Bug fixes should 33 | have accompanying regression tests. 34 | - Tests are located in `/tests` with one file per class. 35 | - Please don't change the `__version__`. We'll take care of bumping 36 | the version when we next release. 37 | - Lint your code with Flake8 before submitting. 38 | 39 | Style 40 | ----- 41 | 42 | We enforce Flake8 rules. 43 | 44 | License 45 | ------- 46 | 47 | All contributions are under the CLA mentioned above. For this project, 48 | Optimizely uses the Apache 2.0 license, and so asks that by contributing 49 | your code, you agree to license your contribution under the terms of the 50 | [Apache License v2.0](http://www.apache.org/licenses/LICENSE-2.0). Your 51 | contributions should also include the following header: 52 | 53 | # Copyright YEAR, Optimizely, Inc. and contributors 54 | # 55 | # Licensed under the Apache License, Version 2.0 (the "License"); 56 | # you may not use this file except in compliance with the License. 57 | # You may obtain a copy of the License at 58 | # 59 | # http://www.apache.org/licenses/LICENSE-2.0 60 | # 61 | # Unless required by applicable law or agreed to in writing, software 62 | # distributed under the License is distributed on an "AS IS" BASIS, 63 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 64 | # See the License for the specific language governing permissions and 65 | # limitations under the License. 66 | 67 | The YEAR above should be the year of the contribution. If work on the 68 | file has been done over multiple years, list each year in the section 69 | above. Example: Optimizely writes the file and releases it in 2014. No 70 | changes are made in 2015. Change made in 2016. YEAR should be "2014, 71 | 2016". 72 | 73 | Contact 74 | ------- 75 | 76 | If you have questions, please contact . 77 | -------------------------------------------------------------------------------- /tests/test_event_dispatcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2018, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from unittest import mock 15 | import json 16 | import unittest 17 | from requests import exceptions as request_exception 18 | 19 | from optimizely import event_builder 20 | from optimizely import event_dispatcher 21 | from optimizely.helpers.enums import EventDispatchConfig 22 | 23 | 24 | class EventDispatcherTest(unittest.TestCase): 25 | def test_dispatch_event__get_request(self): 26 | """ Test that dispatch event fires off requests call with provided URL and params. """ 27 | 28 | url = 'https://www.optimizely.com' 29 | params = {'a': '111001', 'n': 'test_event', 'g': '111028', 'u': 'oeutest_user'} 30 | event = event_builder.Event(url, params) 31 | 32 | with mock.patch('requests.Session.get') as mock_request_get: 33 | event_dispatcher.EventDispatcher.dispatch_event(event) 34 | 35 | mock_request_get.assert_called_once_with(url, params=params, timeout=EventDispatchConfig.REQUEST_TIMEOUT) 36 | 37 | def test_dispatch_event__post_request(self): 38 | """ Test that dispatch event fires off requests call with provided URL, params, HTTP verb and headers. """ 39 | 40 | url = 'https://www.optimizely.com' 41 | params = { 42 | 'accountId': '111001', 43 | 'eventName': 'test_event', 44 | 'eventEntityId': '111028', 45 | 'visitorId': 'oeutest_user', 46 | } 47 | event = event_builder.Event(url, params, http_verb='POST', headers={'Content-Type': 'application/json'}) 48 | 49 | with mock.patch('requests.Session.post') as mock_request_post: 50 | event_dispatcher.EventDispatcher.dispatch_event(event) 51 | 52 | mock_request_post.assert_called_once_with( 53 | url, 54 | data=json.dumps(params), 55 | headers={'Content-Type': 'application/json'}, 56 | timeout=EventDispatchConfig.REQUEST_TIMEOUT, 57 | ) 58 | 59 | def test_dispatch_event__handle_request_exception(self): 60 | """ Test that dispatch event handles exceptions and logs error. """ 61 | 62 | url = 'https://www.optimizely.com' 63 | params = { 64 | 'accountId': '111001', 65 | 'eventName': 'test_event', 66 | 'eventEntityId': '111028', 67 | 'visitorId': 'oeutest_user', 68 | } 69 | event = event_builder.Event(url, params, http_verb='POST', headers={'Content-Type': 'application/json'}) 70 | 71 | with mock.patch( 72 | 'requests.Session.post', side_effect=request_exception.RequestException('Failed Request'), 73 | ) as mock_request_post, mock.patch('logging.error') as mock_log_error: 74 | event_dispatcher.EventDispatcher.dispatch_event(event) 75 | 76 | mock_request_post.assert_called_once_with( 77 | url, 78 | data=json.dumps(params), 79 | headers={'Content-Type': 'application/json'}, 80 | timeout=EventDispatchConfig.REQUEST_TIMEOUT, 81 | ) 82 | mock_log_error.assert_called_once_with('Dispatch event failed. Error: Failed Request') 83 | -------------------------------------------------------------------------------- /optimizely/odp/odp_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from enum import Enum 16 | 17 | from typing import Optional 18 | from threading import Lock 19 | 20 | 21 | class OdpConfigState(Enum): 22 | """State of the ODP integration.""" 23 | UNDETERMINED = 1 24 | INTEGRATED = 2 25 | NOT_INTEGRATED = 3 26 | 27 | 28 | class OdpConfig: 29 | """ 30 | Contains configuration used for ODP integration. 31 | 32 | Args: 33 | api_host: The host URL for the ODP audience segments API (optional). 34 | api_key: The public API key for the ODP account from which the audience segments will be fetched (optional). 35 | segments_to_check: A list of all ODP segments used in the current datafile 36 | (associated with api_host/api_key). 37 | """ 38 | def __init__( 39 | self, 40 | api_key: Optional[str] = None, 41 | api_host: Optional[str] = None, 42 | segments_to_check: Optional[list[str]] = None 43 | ) -> None: 44 | self._api_key = api_key 45 | self._api_host = api_host 46 | self._segments_to_check = segments_to_check or [] 47 | self.lock = Lock() 48 | self._odp_state = OdpConfigState.UNDETERMINED 49 | if self._api_host and self._api_key: 50 | self._odp_state = OdpConfigState.INTEGRATED 51 | 52 | def update(self, api_key: Optional[str], api_host: Optional[str], segments_to_check: list[str]) -> bool: 53 | """ 54 | Override the ODP configuration. 55 | 56 | Args: 57 | api_host: The host URL for the ODP audience segments API (optional). 58 | api_key: The public API key for the ODP account from which the audience segments will be fetched (optional). 59 | segments_to_check: A list of all ODP segments used in the current datafile 60 | (associated with api_host/api_key). 61 | 62 | Returns: 63 | True if the provided values were different than the existing values. 64 | """ 65 | 66 | updated = False 67 | with self.lock: 68 | if api_key and api_host: 69 | self._odp_state = OdpConfigState.INTEGRATED 70 | else: 71 | self._odp_state = OdpConfigState.NOT_INTEGRATED 72 | 73 | if self._api_key != api_key or self._api_host != api_host or self._segments_to_check != segments_to_check: 74 | self._api_key = api_key 75 | self._api_host = api_host 76 | self._segments_to_check = segments_to_check 77 | updated = True 78 | 79 | return updated 80 | 81 | def get_api_host(self) -> Optional[str]: 82 | with self.lock: 83 | return self._api_host 84 | 85 | def get_api_key(self) -> Optional[str]: 86 | with self.lock: 87 | return self._api_key 88 | 89 | def get_segments_to_check(self) -> list[str]: 90 | with self.lock: 91 | return self._segments_to_check.copy() 92 | 93 | def odp_state(self) -> OdpConfigState: 94 | """Returns the state of ODP integration (UNDETERMINED, INTEGRATED, or NOT_INTEGRATED).""" 95 | with self.lock: 96 | return self._odp_state 97 | -------------------------------------------------------------------------------- /optimizely/helpers/sdk_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # https://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | 15 | from typing import Optional 16 | 17 | from optimizely.helpers import enums 18 | from optimizely.odp.lru_cache import OptimizelySegmentsCache 19 | from optimizely.odp.odp_event_manager import OdpEventManager 20 | from optimizely.odp.odp_segment_manager import OdpSegmentManager 21 | 22 | 23 | class OptimizelySdkSettings: 24 | """Contains configuration used for Optimizely Project initialization.""" 25 | 26 | def __init__( 27 | self, 28 | odp_disabled: bool = False, 29 | segments_cache_size: int = enums.OdpSegmentsCacheConfig.DEFAULT_CAPACITY, 30 | segments_cache_timeout_in_secs: int = enums.OdpSegmentsCacheConfig.DEFAULT_TIMEOUT_SECS, 31 | odp_segments_cache: Optional[OptimizelySegmentsCache] = None, 32 | odp_segment_manager: Optional[OdpSegmentManager] = None, 33 | odp_event_manager: Optional[OdpEventManager] = None, 34 | odp_segment_request_timeout: Optional[int] = None, 35 | odp_event_request_timeout: Optional[int] = None, 36 | odp_event_flush_interval: Optional[int] = None, 37 | cmab_prediction_endpoint: Optional[str] = None 38 | ) -> None: 39 | """ 40 | Args: 41 | odp_disabled: Set this flag to true (default = False) to disable ODP features. 42 | segments_cache_size: The maximum size of audience segments cache (optional. default = 10,000). 43 | Set to zero to disable caching. 44 | segments_cache_timeout_in_secs: The timeout in seconds of audience segments cache (optional. default = 600). 45 | Set to zero to disable timeout. 46 | odp_segments_cache: A custom odp segments cache. Required methods include: 47 | `save(key, value)`, `lookup(key) -> value`, and `reset()` 48 | odp_segment_manager: A custom odp segment manager. Required method is: 49 | `fetch_qualified_segments(user_key, user_value, options)`. 50 | odp_event_manager: A custom odp event manager. Required method is: 51 | `send_event(type:, action:, identifiers:, data:)` 52 | odp_segment_request_timeout: Time to wait in seconds for fetch_qualified_segments request to 53 | send successfully (optional). 54 | odp_event_request_timeout: Time to wait in seconds for send_odp_events request to send successfully. 55 | odp_event_flush_interval: Time to wait for events to accumulate before sending a batch in seconds (optional). 56 | cmab_prediction_endpoint: Custom CMAB prediction endpoint URL template (optional). 57 | Use {} as placeholder for rule_id. Defaults to production endpoint if not provided. 58 | """ 59 | 60 | self.odp_disabled = odp_disabled 61 | self.segments_cache_size = segments_cache_size 62 | self.segments_cache_timeout_in_secs = segments_cache_timeout_in_secs 63 | self.segments_cache = odp_segments_cache 64 | self.odp_segment_manager = odp_segment_manager 65 | self.odp_event_manager = odp_event_manager 66 | self.fetch_segments_timeout = odp_segment_request_timeout 67 | self.odp_event_timeout = odp_event_request_timeout 68 | self.odp_flush_interval = odp_event_flush_interval 69 | self.cmab_prediction_endpoint = cmab_prediction_endpoint 70 | -------------------------------------------------------------------------------- /tests/test_notification_center_registry.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import json 15 | from unittest import mock 16 | import copy 17 | 18 | from optimizely.notification_center_registry import _NotificationCenterRegistry 19 | from optimizely.notification_center import NotificationCenter 20 | from optimizely.optimizely import Optimizely 21 | from optimizely.helpers.enums import NotificationTypes, Errors 22 | from .base import BaseTest 23 | 24 | 25 | class NotificationCenterRegistryTest(BaseTest): 26 | def test_get_notification_center(self): 27 | logger = mock.MagicMock() 28 | sdk_key = 'test' 29 | client = Optimizely(sdk_key=sdk_key, logger=logger) 30 | notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) 31 | self.assertIsInstance(notification_center, NotificationCenter) 32 | config_notifications = notification_center.notification_listeners[NotificationTypes.OPTIMIZELY_CONFIG_UPDATE] 33 | 34 | self.assertIn((mock.ANY, client._update_odp_config_on_datafile_update), config_notifications) 35 | 36 | logger.error.assert_not_called() 37 | 38 | _NotificationCenterRegistry.get_notification_center(None, logger) 39 | 40 | logger.error.assert_called_once_with(f'{Errors.MISSING_SDK_KEY} ODP may not work properly without it.') 41 | 42 | client.close() 43 | 44 | def test_only_one_notification_center_created(self): 45 | logger = mock.MagicMock() 46 | sdk_key = 'single' 47 | notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) 48 | client = Optimizely(sdk_key=sdk_key, logger=logger) 49 | 50 | self.assertIs(notification_center, _NotificationCenterRegistry.get_notification_center(sdk_key, logger)) 51 | 52 | logger.error.assert_not_called() 53 | 54 | client.close() 55 | 56 | def test_remove_notification_center(self): 57 | logger = mock.MagicMock() 58 | sdk_key = 'segments-test' 59 | test_datafile = json.dumps(self.config_dict_with_audience_segments) 60 | test_response = self.fake_server_response(status_code=200, content=test_datafile) 61 | notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) 62 | 63 | with mock.patch('requests.Session.get', return_value=test_response), \ 64 | mock.patch.object(notification_center, 'send_notifications') as mock_send: 65 | 66 | client = Optimizely(sdk_key=sdk_key, logger=logger) 67 | client.config_manager.get_config() 68 | 69 | mock_send.assert_called_once() 70 | mock_send.reset_mock() 71 | 72 | self.assertIn(notification_center, _NotificationCenterRegistry._notification_centers.values()) 73 | _NotificationCenterRegistry.remove_notification_center(sdk_key) 74 | self.assertNotIn(notification_center, _NotificationCenterRegistry._notification_centers.values()) 75 | 76 | revised_datafile = copy.deepcopy(self.config_dict_with_audience_segments) 77 | revised_datafile['revision'] = str(int(revised_datafile['revision']) + 1) 78 | 79 | # trigger notification 80 | client.config_manager._set_config(json.dumps(revised_datafile)) 81 | mock_send.assert_not_called() 82 | 83 | logger.error.assert_not_called() 84 | 85 | client.close() 86 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | lint_markdown_files: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: '2.6' 21 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 22 | - name: Install gem 23 | run: | 24 | gem install awesome_bot 25 | - name: Run tests 26 | run: find . -type f -name '*.md' -exec awesome_bot {} \; 27 | 28 | linting: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Set up Python 3.12 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: '3.12' 36 | # flake8 version should be same as the version in requirements/test.txt 37 | # to avoid lint errors on CI 38 | - name: pip install flak8 39 | run: pip install flake8>=4.1.0 40 | - name: Lint with flake8 41 | run: | 42 | flake8 43 | # stop the build if there are Python syntax errors or undefined names 44 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 45 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 46 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 47 | 48 | integration_tests: 49 | uses: optimizely/python-sdk/.github/workflows/integration_test.yml@master 50 | secrets: 51 | CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} 52 | TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} 53 | 54 | fullstack_production_suite: 55 | uses: optimizely/python-sdk/.github/workflows/integration_test.yml@master 56 | with: 57 | FULLSTACK_TEST_REPO: ProdTesting 58 | secrets: 59 | CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} 60 | TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} 61 | 62 | test: 63 | runs-on: ubuntu-latest 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | python-version: 68 | - "pypy-3.9" 69 | - "pypy-3.10" 70 | - "3.9" 71 | - "3.10" 72 | - "3.11" 73 | - "3.12" 74 | steps: 75 | - uses: actions/checkout@v3 76 | - name: Set up Python ${{ matrix.python-version }} 77 | uses: actions/setup-python@v4 78 | with: 79 | python-version: ${{ matrix.python-version }} 80 | - name: Install dependencies 81 | run: | 82 | python -m pip install --upgrade pip 83 | pip install -r requirements/core.txt;pip install -r requirements/test.txt 84 | - name: Test with pytest 85 | run: | 86 | pytest --cov=optimizely 87 | 88 | type-check: 89 | runs-on: ubuntu-latest 90 | strategy: 91 | fail-fast: false 92 | matrix: 93 | python-version: 94 | - "pypy-3.9" 95 | - "pypy-3.10" 96 | - "3.9" 97 | - "3.10" 98 | - "3.11" 99 | - "3.12" 100 | steps: 101 | - uses: actions/checkout@v3 102 | - name: Set up Python ${{ matrix.python-version }} 103 | uses: actions/setup-python@v4 104 | with: 105 | python-version: ${{ matrix.python-version }} 106 | - name: Install dependencies 107 | run: | 108 | python -m pip install --upgrade pip 109 | pip install -r requirements/typing.txt 110 | - name: Type check with mypy 111 | run: | 112 | mypy . --exclude "tests/testapp" 113 | mypy . --exclude "tests/" --strict 114 | -------------------------------------------------------------------------------- /optimizely/helpers/types.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | from __future__ import annotations 14 | 15 | from typing import Literal, Optional, Any 16 | from sys import version_info 17 | 18 | 19 | if version_info < (3, 8): 20 | from typing_extensions import TypedDict 21 | else: 22 | from typing import TypedDict # type: ignore 23 | 24 | 25 | # Intermediate types for type checking deserialized datafile json before actual class instantiation. 26 | # These aren't used for anything other than type signatures 27 | 28 | class BaseEntity(TypedDict): 29 | pass 30 | 31 | 32 | class BaseDict(BaseEntity): 33 | """Base type for parsed datafile json, before instantiation of class objects.""" 34 | id: str 35 | key: str 36 | 37 | 38 | class EventDict(BaseDict): 39 | """Event dict from parsed datafile json.""" 40 | experimentIds: list[str] 41 | 42 | 43 | class AttributeDict(BaseDict): 44 | """Attribute dict from parsed datafile json.""" 45 | pass 46 | 47 | 48 | class TrafficAllocation(BaseEntity): 49 | """Traffic Allocation dict from parsed datafile json.""" 50 | endOfRange: int 51 | entityId: str 52 | 53 | 54 | class VariableDict(BaseDict): 55 | """Variable dict from parsed datafile json.""" 56 | value: str 57 | type: str 58 | defaultValue: str 59 | subType: str 60 | 61 | 62 | class VariationDict(BaseDict): 63 | """Variation dict from parsed datafile json.""" 64 | variables: list[VariableDict] 65 | featureEnabled: Optional[bool] 66 | 67 | 68 | class ExperimentDict(BaseDict): 69 | """Experiment dict from parsed datafile json.""" 70 | status: str 71 | forcedVariations: dict[str, str] 72 | variations: list[VariationDict] 73 | layerId: str 74 | audienceIds: list[str] 75 | audienceConditions: list[str | list[str]] 76 | trafficAllocation: list[TrafficAllocation] 77 | 78 | 79 | class RolloutDict(BaseEntity): 80 | """Rollout dict from parsed datafile json.""" 81 | id: str 82 | experiments: list[ExperimentDict] 83 | 84 | 85 | class FeatureFlagDict(BaseDict): 86 | """Feature flag dict from parsed datafile json.""" 87 | rolloutId: str 88 | variables: list[VariableDict] 89 | experimentIds: list[str] 90 | 91 | 92 | class GroupDict(BaseEntity): 93 | """Group dict from parsed datafile json.""" 94 | id: str 95 | policy: str 96 | experiments: list[ExperimentDict] 97 | trafficAllocation: list[TrafficAllocation] 98 | 99 | 100 | class AudienceDict(BaseEntity): 101 | """Audience dict from parsed datafile json.""" 102 | id: str 103 | name: str 104 | conditions: list[Any] | str 105 | 106 | 107 | class IntegrationDict(BaseEntity): 108 | """Integration dict from parsed datafile json.""" 109 | key: str 110 | host: str 111 | publicKey: str 112 | 113 | 114 | class CmabDict(BaseEntity): 115 | """Cmab dict from parsed datafile json.""" 116 | attributeIds: list[str] 117 | trafficAllocation: int 118 | 119 | 120 | HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived'] 121 | 122 | 123 | class HoldoutDict(ExperimentDict): 124 | """Holdout dict from parsed datafile json. 125 | 126 | Extends ExperimentDict with holdout-specific properties. 127 | """ 128 | holdoutStatus: HoldoutStatus 129 | includedFlags: list[str] 130 | excludedFlags: list[str] 131 | -------------------------------------------------------------------------------- /optimizely/event/user_event.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | from __future__ import annotations 14 | import time 15 | import uuid 16 | from typing import TYPE_CHECKING, Optional 17 | from sys import version_info 18 | 19 | from optimizely import version 20 | 21 | 22 | if version_info < (3, 8): 23 | from typing_extensions import Final 24 | else: 25 | from typing import Final 26 | 27 | 28 | if TYPE_CHECKING: 29 | # prevent circular dependenacy by skipping import at runtime 30 | from optimizely.entities import Experiment, Variation, Event 31 | from optimizely.event.payload import VisitorAttribute 32 | from optimizely.helpers.event_tag_utils import EventTags 33 | 34 | 35 | CLIENT_NAME: Final = 'python-sdk' 36 | 37 | 38 | class UserEvent: 39 | """ Class respresenting User Event. """ 40 | 41 | def __init__( 42 | self, event_context: EventContext, user_id: str, 43 | visitor_attributes: list[VisitorAttribute], bot_filtering: Optional[bool] = None 44 | ): 45 | self.event_context = event_context 46 | self.user_id = user_id 47 | self.visitor_attributes = visitor_attributes 48 | self.bot_filtering = bot_filtering 49 | self.uuid = self._get_uuid() 50 | self.timestamp = self._get_time() 51 | 52 | def _get_time(self) -> int: 53 | return int(round(time.time() * 1000)) 54 | 55 | def _get_uuid(self) -> str: 56 | return str(uuid.uuid4()) 57 | 58 | 59 | class ImpressionEvent(UserEvent): 60 | """ Class representing Impression Event. """ 61 | 62 | def __init__( 63 | self, 64 | event_context: EventContext, 65 | user_id: str, 66 | experiment: Experiment, 67 | visitor_attributes: list[VisitorAttribute], 68 | variation: Optional[Variation], 69 | flag_key: str, 70 | rule_key: str, 71 | rule_type: str, 72 | enabled: bool, 73 | bot_filtering: Optional[bool] = None, 74 | cmab_uuid: Optional[str] = None 75 | ): 76 | super().__init__(event_context, user_id, visitor_attributes, bot_filtering) 77 | self.experiment = experiment 78 | self.variation = variation 79 | self.flag_key = flag_key 80 | self.rule_key = rule_key 81 | self.rule_type = rule_type 82 | self.enabled = enabled 83 | self.cmab_uuid = cmab_uuid 84 | 85 | 86 | class ConversionEvent(UserEvent): 87 | """ Class representing Conversion Event. """ 88 | 89 | def __init__( 90 | self, event_context: EventContext, event: Optional[Event], user_id: str, 91 | visitor_attributes: list[VisitorAttribute], event_tags: Optional[EventTags], 92 | bot_filtering: Optional[bool] = None, 93 | ): 94 | super().__init__(event_context, user_id, visitor_attributes, bot_filtering) 95 | self.event = event 96 | self.event_tags = event_tags 97 | 98 | 99 | class EventContext: 100 | """ Class respresenting User Event Context. """ 101 | 102 | def __init__(self, account_id: str, project_id: str, revision: str, anonymize_ip: bool, region: str): 103 | self.account_id = account_id 104 | self.project_id = project_id 105 | self.revision = revision 106 | self.client_name = CLIENT_NAME 107 | self.client_version = version.__version__ 108 | self.anonymize_ip = anonymize_ip 109 | self.region = region or 'US' 110 | -------------------------------------------------------------------------------- /optimizely/odp/odp_segment_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | 16 | from typing import Optional 17 | 18 | from optimizely import logger as optimizely_logger 19 | from optimizely.helpers.enums import Errors 20 | from optimizely.odp.odp_config import OdpConfig 21 | from optimizely.odp.optimizely_odp_option import OptimizelyOdpOption 22 | from optimizely.odp.lru_cache import OptimizelySegmentsCache 23 | from optimizely.odp.odp_segment_api_manager import OdpSegmentApiManager 24 | 25 | 26 | class OdpSegmentManager: 27 | """Schedules connections to ODP for audience segmentation and caches the results.""" 28 | 29 | def __init__( 30 | self, 31 | segments_cache: OptimizelySegmentsCache, 32 | api_manager: Optional[OdpSegmentApiManager] = None, 33 | logger: Optional[optimizely_logger.Logger] = None, 34 | timeout: Optional[int] = None 35 | ) -> None: 36 | 37 | self.odp_config: Optional[OdpConfig] = None 38 | self.segments_cache = segments_cache 39 | self.logger = logger or optimizely_logger.NoOpLogger() 40 | self.api_manager = api_manager or OdpSegmentApiManager(self.logger, timeout) 41 | 42 | def fetch_qualified_segments(self, user_key: str, user_value: str, options: list[str]) -> Optional[list[str]]: 43 | """ 44 | Args: 45 | user_key: The key for identifying the id type. 46 | user_value: The id itself. 47 | options: An array of OptimizelySegmentOptions used to ignore and/or reset the cache. 48 | 49 | Returns: 50 | Qualified segments for the user from the cache or the ODP server if not in the cache. 51 | """ 52 | if self.odp_config: 53 | odp_api_key = self.odp_config.get_api_key() 54 | odp_api_host = self.odp_config.get_api_host() 55 | odp_segments_to_check = self.odp_config.get_segments_to_check() 56 | 57 | if not self.odp_config or not (odp_api_key and odp_api_host): 58 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('api_key/api_host not defined')) 59 | return None 60 | 61 | if not odp_segments_to_check: 62 | self.logger.debug('No segments are used in the project. Returning empty list.') 63 | return [] 64 | 65 | cache_key = self.make_cache_key(user_key, user_value) 66 | 67 | ignore_cache = OptimizelyOdpOption.IGNORE_CACHE in options 68 | reset_cache = OptimizelyOdpOption.RESET_CACHE in options 69 | 70 | if reset_cache: 71 | self.reset() 72 | 73 | if not ignore_cache and not reset_cache: 74 | segments = self.segments_cache.lookup(cache_key) 75 | if segments: 76 | self.logger.debug('ODP cache hit. Returning segments from cache.') 77 | return segments 78 | self.logger.debug('ODP cache miss.') 79 | 80 | self.logger.debug('Making a call to ODP server.') 81 | 82 | segments = self.api_manager.fetch_segments(odp_api_key, odp_api_host, user_key, user_value, 83 | odp_segments_to_check) 84 | 85 | if segments and not ignore_cache: 86 | self.segments_cache.save(cache_key, segments) 87 | 88 | return segments 89 | 90 | def reset(self) -> None: 91 | self.segments_cache.reset() 92 | 93 | def make_cache_key(self, user_key: str, user_value: str) -> str: 94 | return f'{user_key}-$-{user_value}' 95 | -------------------------------------------------------------------------------- /optimizely/odp/odp_event_api_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | 16 | import json 17 | from typing import Optional 18 | 19 | import requests 20 | from requests.exceptions import RequestException, ConnectionError, Timeout 21 | 22 | from optimizely import logger as optimizely_logger 23 | from optimizely.helpers.enums import Errors, OdpEventApiConfig 24 | from optimizely.odp.odp_event import OdpEvent, OdpEventEncoder 25 | 26 | """ 27 | ODP REST Events API 28 | - https://api.zaius.com/v3/events 29 | - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" 30 | 31 | [Event Request] 32 | curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d 33 | '{"type":"fullstack","action":"identified","identifiers":{"vuid": "123","fs_user_id": "abc"}, 34 | "data":{"idempotence_id":"xyz","source":"swift-sdk"}}' https://api.zaius.com/v3/events 35 | [Event Response] 36 | {"title":"Accepted","status":202,"timestamp":"2022-06-30T20:59:52.046Z"} 37 | """ 38 | 39 | 40 | class OdpEventApiManager: 41 | """Provides an internal service for ODP event REST api access.""" 42 | 43 | def __init__(self, logger: Optional[optimizely_logger.Logger] = None, timeout: Optional[int] = None): 44 | self.logger = logger or optimizely_logger.NoOpLogger() 45 | self.timeout = timeout or OdpEventApiConfig.REQUEST_TIMEOUT 46 | 47 | def send_odp_events(self, 48 | api_key: str, 49 | api_host: str, 50 | events: list[OdpEvent]) -> bool: 51 | """ 52 | Dispatch the event being represented by the OdpEvent object. 53 | 54 | Args: 55 | api_key: public api key 56 | api_host: domain url of the host 57 | events: list of odp events to be sent to optimizely's odp platform. 58 | 59 | Returns: 60 | retry is True - if network or server error (5xx), otherwise False 61 | """ 62 | should_retry = False 63 | url = f'{api_host}/v3/events' 64 | request_headers = {'content-type': 'application/json', 'x-api-key': api_key} 65 | 66 | try: 67 | payload_dict = json.dumps(events, cls=OdpEventEncoder) 68 | except TypeError as err: 69 | self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) 70 | return should_retry 71 | 72 | try: 73 | response = requests.post(url=url, 74 | headers=request_headers, 75 | data=payload_dict, 76 | timeout=self.timeout) 77 | 78 | response.raise_for_status() 79 | 80 | except (ConnectionError, Timeout): 81 | self.logger.error(Errors.ODP_EVENT_FAILED.format('network error')) 82 | # retry on network errors 83 | should_retry = True 84 | except RequestException as err: 85 | if err.response is not None: 86 | if 400 <= err.response.status_code < 500: 87 | # log 4xx 88 | self.logger.error(Errors.ODP_EVENT_FAILED.format(err.response.text)) 89 | else: 90 | # log 5xx 91 | self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) 92 | # retry on 500 exceptions 93 | should_retry = True 94 | else: 95 | # log exceptions without response body (i.e. invalid url) 96 | self.logger.error(Errors.ODP_EVENT_FAILED.format(err)) 97 | 98 | return should_retry 99 | -------------------------------------------------------------------------------- /optimizely/helpers/audience.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2018-2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | import json 16 | from typing import TYPE_CHECKING, Optional, Sequence, Type 17 | 18 | from . import condition as condition_helper 19 | from . import condition_tree_evaluator 20 | from optimizely import optimizely_user_context 21 | 22 | if TYPE_CHECKING: 23 | # prevent circular dependenacy by skipping import at runtime 24 | from optimizely.project_config import ProjectConfig 25 | from optimizely.logger import Logger 26 | from optimizely.helpers.enums import ExperimentAudienceEvaluationLogs, RolloutRuleAudienceEvaluationLogs 27 | 28 | 29 | def does_user_meet_audience_conditions( 30 | config: ProjectConfig, 31 | audience_conditions: Optional[Sequence[str | list[str]]], 32 | audience_logs: Type[ExperimentAudienceEvaluationLogs | RolloutRuleAudienceEvaluationLogs], 33 | logging_key: str, 34 | user_context: optimizely_user_context.OptimizelyUserContext, 35 | logger: Logger 36 | ) -> tuple[bool, list[str]]: 37 | """ Determine for given experiment if user satisfies the audiences for the experiment. 38 | 39 | Args: 40 | config: project_config.ProjectConfig object representing the project. 41 | audience_conditions: Audience conditions corresponding to the experiment or rollout rule. 42 | audience_logs: Log class capturing the messages to be logged . 43 | logging_key: String representing experiment key or rollout rule. To be used in log messages only. 44 | attributes: Dict representing user attributes which will be used in determining 45 | if the audience conditions are met. If not provided, default to an empty dict. 46 | logger: Provides a logger to send log messages to. 47 | 48 | Returns: 49 | Boolean representing if user satisfies audience conditions for any of the audiences or not 50 | And an array of log messages representing decision making. 51 | """ 52 | decide_reasons = [] 53 | message = audience_logs.EVALUATING_AUDIENCES_COMBINED.format(logging_key, json.dumps(audience_conditions)) 54 | logger.debug(message) 55 | decide_reasons.append(message) 56 | 57 | # Return True in case there are no audiences 58 | if audience_conditions is None or audience_conditions == []: 59 | message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, 'TRUE') 60 | logger.info(message) 61 | decide_reasons.append(message) 62 | 63 | return True, decide_reasons 64 | 65 | def evaluate_custom_attr(audience_id: str, index: int) -> Optional[bool]: 66 | audience = config.get_audience(audience_id) 67 | if not audience or audience.conditionList is None: 68 | return None 69 | custom_attr_condition_evaluator = condition_helper.CustomAttributeConditionEvaluator( 70 | audience.conditionList, user_context, logger 71 | ) 72 | 73 | return custom_attr_condition_evaluator.evaluate(index) 74 | 75 | def evaluate_audience(audience_id: str) -> Optional[bool]: 76 | audience = config.get_audience(audience_id) 77 | 78 | if audience is None: 79 | return None 80 | _message = audience_logs.EVALUATING_AUDIENCE.format(audience_id, audience.conditions) 81 | logger.debug(_message) 82 | 83 | result = condition_tree_evaluator.evaluate( 84 | audience.conditionStructure, lambda index: evaluate_custom_attr(audience_id, index), 85 | ) 86 | 87 | result_str = str(result).upper() if result is not None else 'UNKNOWN' 88 | _message = audience_logs.AUDIENCE_EVALUATION_RESULT.format(audience_id, result_str) 89 | logger.debug(_message) 90 | 91 | return result 92 | 93 | eval_result = condition_tree_evaluator.evaluate(audience_conditions, evaluate_audience) 94 | eval_result = eval_result or False 95 | message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, str(eval_result).upper()) 96 | logger.info(message) 97 | decide_reasons.append(message) 98 | return eval_result, decide_reasons 99 | -------------------------------------------------------------------------------- /optimizely/odp/lru_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from dataclasses import dataclass, field 16 | import threading 17 | from time import time 18 | from collections import OrderedDict 19 | from typing import Optional, Generic, TypeVar, Hashable 20 | from sys import version_info 21 | 22 | if version_info < (3, 8): 23 | from typing_extensions import Protocol 24 | else: 25 | from typing import Protocol 26 | 27 | # generic type definitions for LRUCache parameters 28 | K = TypeVar('K', bound=Hashable, contravariant=True) 29 | V = TypeVar('V') 30 | 31 | 32 | class LRUCache(Generic[K, V]): 33 | """Least Recently Used cache that invalidates entries older than the timeout.""" 34 | 35 | def __init__(self, capacity: int, timeout_in_secs: int): 36 | self.lock = threading.Lock() 37 | self.map: OrderedDict[K, CacheElement[V]] = OrderedDict() 38 | self.capacity = capacity 39 | self.timeout = timeout_in_secs 40 | 41 | def lookup(self, key: K) -> Optional[V]: 42 | """Return the non-stale value associated with the provided key and move the 43 | element to the end of the cache. If the selected value is stale, remove it from 44 | the cache and clear the entire cache if stale. 45 | """ 46 | if self.capacity <= 0: 47 | return None 48 | 49 | with self.lock: 50 | if key not in self.map: 51 | return None 52 | 53 | self.map.move_to_end(key) 54 | element = self.map[key] 55 | 56 | if element._is_stale(self.timeout): 57 | del self.map[key] 58 | return None 59 | 60 | return element.value 61 | 62 | def save(self, key: K, value: V) -> None: 63 | """Insert and/or move the provided key/value pair to the most recent end of the cache. 64 | If the cache grows beyond the cache capacity, the least recently used element will be 65 | removed. 66 | """ 67 | if self.capacity <= 0: 68 | return 69 | 70 | with self.lock: 71 | if key in self.map: 72 | self.map.move_to_end(key) 73 | 74 | self.map[key] = CacheElement(value) 75 | 76 | if len(self.map) > self.capacity: 77 | self.map.popitem(last=False) 78 | 79 | def reset(self) -> None: 80 | """ Clear the cache.""" 81 | if self.capacity <= 0: 82 | return 83 | with self.lock: 84 | self.map.clear() 85 | 86 | def peek(self, key: K) -> Optional[V]: 87 | """Returns the value associated with the provided key without updating the cache.""" 88 | if self.capacity <= 0: 89 | return None 90 | with self.lock: 91 | element = self.map.get(key) 92 | return element.value if element is not None else None 93 | 94 | def remove(self, key: K) -> None: 95 | """Remove the element associated with the provided key from the cache.""" 96 | with self.lock: 97 | self.map.pop(key, None) 98 | 99 | 100 | @dataclass 101 | class CacheElement(Generic[V]): 102 | """Individual element for the LRUCache.""" 103 | value: V 104 | timestamp: float = field(default_factory=time) 105 | 106 | def _is_stale(self, timeout: float) -> bool: 107 | """Returns True if the provided timeout has passed since the element's timestamp.""" 108 | if timeout <= 0: 109 | return False 110 | return time() - self.timestamp >= timeout 111 | 112 | 113 | class OptimizelySegmentsCache(Protocol): 114 | """Protocol for implementing custom cache.""" 115 | def reset(self) -> None: 116 | """ Clear the cache.""" 117 | ... 118 | 119 | def lookup(self, key: str) -> Optional[list[str]]: 120 | """Return the value associated with the provided key.""" 121 | ... 122 | 123 | def save(self, key: str, value: list[str]) -> None: 124 | """Save the key/value pair in the cache.""" 125 | ... 126 | -------------------------------------------------------------------------------- /optimizely/event/payload.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | import json 16 | from numbers import Integral 17 | from typing import TYPE_CHECKING, Any, Optional 18 | 19 | 20 | if TYPE_CHECKING: 21 | from optimizely.helpers.event_tag_utils import EventTags 22 | 23 | 24 | class EventBatch: 25 | """ Class respresenting Event Batch. """ 26 | 27 | def __init__( 28 | self, 29 | account_id: str, 30 | project_id: str, 31 | revision: str, 32 | client_name: str, 33 | client_version: str, 34 | anonymize_ip: bool, 35 | enrich_decisions: bool = True, 36 | visitors: Optional[list[Visitor]] = None, 37 | ): 38 | self.account_id = account_id 39 | self.project_id = project_id 40 | self.revision = revision 41 | self.client_name = client_name 42 | self.client_version = client_version 43 | self.anonymize_ip = anonymize_ip 44 | self.enrich_decisions = enrich_decisions 45 | self.visitors = visitors or [] 46 | 47 | def __eq__(self, other: object) -> bool: 48 | batch_obj = self.get_event_params() 49 | return batch_obj == other 50 | 51 | def _dict_clean(self, obj: list[tuple[str, Any]]) -> dict[str, Any]: 52 | """ Helper method to remove keys from dictionary with None values. """ 53 | 54 | result = {} 55 | for k, v in obj: 56 | if v is None and k in ['revenue', 'value', 'tags', 'decisions']: 57 | continue 58 | else: 59 | result[k] = v 60 | return result 61 | 62 | def get_event_params(self) -> dict[str, Any]: 63 | """ Method to return valid params for LogEvent payload. """ 64 | 65 | return json.loads( # type: ignore[no-any-return] 66 | json.dumps(self.__dict__, default=lambda o: o.__dict__), 67 | object_pairs_hook=self._dict_clean, 68 | ) 69 | 70 | 71 | class Decision: 72 | """ Class respresenting Decision. """ 73 | 74 | def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, metadata: Metadata): 75 | self.campaign_id = campaign_id 76 | self.experiment_id = experiment_id 77 | self.variation_id = variation_id 78 | self.metadata = metadata 79 | 80 | 81 | class Metadata: 82 | """ Class respresenting Metadata. """ 83 | 84 | def __init__(self, flag_key: str, rule_key: str, rule_type: str, 85 | variation_key: str, enabled: bool, cmab_uuid: Optional[str] = None): 86 | self.flag_key = flag_key 87 | self.rule_key = rule_key 88 | self.rule_type = rule_type 89 | self.variation_key = variation_key 90 | self.enabled = enabled 91 | if cmab_uuid: 92 | self.cmab_uuid = cmab_uuid 93 | 94 | 95 | class Snapshot: 96 | """ Class representing Snapshot. """ 97 | 98 | def __init__(self, events: list[SnapshotEvent], decisions: Optional[list[Decision]] = None): 99 | self.events = events 100 | self.decisions = decisions 101 | 102 | 103 | class SnapshotEvent: 104 | """ Class representing Snapshot Event. """ 105 | 106 | def __init__( 107 | self, 108 | entity_id: str, 109 | uuid: str, 110 | key: str, 111 | timestamp: int, 112 | revenue: Optional[Integral] = None, 113 | value: Any = None, 114 | tags: Optional[EventTags] = None 115 | ): 116 | self.entity_id = entity_id 117 | self.uuid = uuid 118 | self.key = key 119 | self.timestamp = timestamp 120 | self.revenue = revenue 121 | self.value = value 122 | self.tags = tags 123 | 124 | 125 | class Visitor: 126 | """ Class representing Visitor. """ 127 | 128 | def __init__(self, snapshots: list[Snapshot], attributes: list[VisitorAttribute], visitor_id: str): 129 | self.snapshots = snapshots 130 | self.attributes = attributes 131 | self.visitor_id = visitor_id 132 | 133 | 134 | class VisitorAttribute: 135 | """ Class representing Visitor Attribute. """ 136 | 137 | def __init__(self, entity_id: str, key: str, attribute_type: str, value: Any): 138 | self.entity_id = entity_id 139 | self.key = key 140 | self.type = attribute_type 141 | self.value = value 142 | -------------------------------------------------------------------------------- /optimizely/helpers/condition_tree_evaluator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import Any, Callable, Optional, Sequence 16 | 17 | from .condition import ConditionOperatorTypes 18 | 19 | 20 | LeafEvaluator = Callable[[Any], Optional[bool]] 21 | 22 | 23 | def and_evaluator(conditions: Sequence[str | list[str]], leaf_evaluator: LeafEvaluator) -> Optional[bool]: 24 | """ Evaluates a list of conditions as if the evaluator had been applied 25 | to each entry and the results AND-ed together. 26 | 27 | Args: 28 | conditions: List of conditions ex: [operand_1, operand_2]. 29 | leaf_evaluator: Function which will be called to evaluate leaf condition values. 30 | 31 | Returns: 32 | Boolean: 33 | - True if all operands evaluate to True. 34 | - False if a single operand evaluates to False. 35 | None: if conditions couldn't be evaluated. 36 | """ 37 | saw_null_result = False 38 | 39 | for condition in conditions: 40 | result = evaluate(condition, leaf_evaluator) 41 | if result is False: 42 | return False 43 | if result is None: 44 | saw_null_result = True 45 | 46 | return None if saw_null_result else True 47 | 48 | 49 | def or_evaluator(conditions: Sequence[str | list[str]], leaf_evaluator: LeafEvaluator) -> Optional[bool]: 50 | """ Evaluates a list of conditions as if the evaluator had been applied 51 | to each entry and the results OR-ed together. 52 | 53 | Args: 54 | conditions: List of conditions ex: [operand_1, operand_2]. 55 | leaf_evaluator: Function which will be called to evaluate leaf condition values. 56 | 57 | Returns: 58 | Boolean: 59 | - True if any operand evaluates to True. 60 | - False if all operands evaluate to False. 61 | None: if conditions couldn't be evaluated. 62 | """ 63 | saw_null_result = False 64 | 65 | for condition in conditions: 66 | result = evaluate(condition, leaf_evaluator) 67 | if result is True: 68 | return True 69 | if result is None: 70 | saw_null_result = True 71 | 72 | return None if saw_null_result else False 73 | 74 | 75 | def not_evaluator(conditions: Sequence[str | list[str]], leaf_evaluator: LeafEvaluator) -> Optional[bool]: 76 | """ Evaluates a list of conditions as if the evaluator had been applied 77 | to a single entry and NOT was applied to the result. 78 | 79 | Args: 80 | conditions: List of conditions ex: [operand_1, operand_2]. 81 | leaf_evaluator: Function which will be called to evaluate leaf condition values. 82 | 83 | Returns: 84 | Boolean: 85 | - True if the operand evaluates to False. 86 | - False if the operand evaluates to True. 87 | None: if conditions is empty or condition couldn't be evaluated. 88 | """ 89 | if not len(conditions) > 0: 90 | return None 91 | 92 | result = evaluate(conditions[0], leaf_evaluator) 93 | return None if result is None else not result 94 | 95 | 96 | EVALUATORS_BY_OPERATOR_TYPE = { 97 | ConditionOperatorTypes.AND: and_evaluator, 98 | ConditionOperatorTypes.OR: or_evaluator, 99 | ConditionOperatorTypes.NOT: not_evaluator, 100 | } 101 | 102 | 103 | def evaluate(conditions: Optional[Sequence[str | list[str]]], leaf_evaluator: LeafEvaluator) -> Optional[bool]: 104 | """ Top level method to evaluate conditions. 105 | 106 | Args: 107 | conditions: Nested array of and/or conditions, or a single leaf condition value of any type. 108 | Example: ['and', '0', ['or', '1', '2']] 109 | leaf_evaluator: Function which will be called to evaluate leaf condition values. 110 | 111 | Returns: 112 | Boolean: Result of evaluating the conditions using the operator rules and the leaf evaluator. 113 | None: if conditions couldn't be evaluated. 114 | 115 | """ 116 | 117 | if isinstance(conditions, list): 118 | if conditions[0] in list(EVALUATORS_BY_OPERATOR_TYPE.keys()): 119 | return EVALUATORS_BY_OPERATOR_TYPE[conditions[0]](conditions[1:], leaf_evaluator) 120 | else: 121 | # assume OR when operator is not explicit. 122 | return EVALUATORS_BY_OPERATOR_TYPE[ConditionOperatorTypes.OR](conditions, leaf_evaluator) 123 | 124 | leaf_condition = conditions 125 | return leaf_evaluator(leaf_condition) 126 | -------------------------------------------------------------------------------- /optimizely/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2018-2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | import logging 14 | from typing import Any, Optional, Union 15 | import warnings 16 | from sys import version_info 17 | 18 | from .helpers import enums 19 | 20 | if version_info < (3, 8): 21 | from typing_extensions import Final 22 | else: 23 | from typing import Final 24 | 25 | 26 | _DEFAULT_LOG_FORMAT: Final = '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s' 27 | 28 | 29 | def reset_logger(name: str, level: Optional[int] = None, handler: Optional[logging.Handler] = None) -> logging.Logger: 30 | """ 31 | Make a standard python logger object with default formatter, handler, etc. 32 | 33 | Defaults are: 34 | - level == logging.INFO 35 | - handler == logging.StreamHandler() 36 | 37 | Args: 38 | name: a logger name. 39 | level: an optional initial log level for this logger. 40 | handler: an optional initial handler for this logger. 41 | 42 | Returns: a standard python logger with a single handler. 43 | 44 | """ 45 | # Make the logger and set its level. 46 | if level is None: 47 | level = logging.INFO 48 | logger = logging.getLogger(name) 49 | logger.setLevel(level) 50 | 51 | # Make the handler and attach it. 52 | handler = handler or logging.StreamHandler() 53 | handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT)) 54 | 55 | # We don't use ``.addHandler``, since this logger may have already been 56 | # instantiated elsewhere with a different handler. It should only ever 57 | # have one, not many. 58 | logger.handlers = [handler] 59 | return logger 60 | 61 | 62 | class BaseLogger: 63 | """ Class encapsulating logging functionality. Override with your own logger providing log method. """ 64 | 65 | @staticmethod 66 | def log(*args: Any) -> None: 67 | pass # pragma: no cover 68 | 69 | @staticmethod 70 | def error(*args: Any) -> None: 71 | pass # pragma: no cover 72 | 73 | @staticmethod 74 | def warning(*args: Any) -> None: 75 | pass # pragma: no cover 76 | 77 | @staticmethod 78 | def info(*args: Any) -> None: 79 | pass # pragma: no cover 80 | 81 | @staticmethod 82 | def debug(*args: Any) -> None: 83 | pass # pragma: no cover 84 | 85 | @staticmethod 86 | def exception(*args: Any) -> None: 87 | pass # pragma: no cover 88 | 89 | 90 | # type alias for optimizely logger 91 | Logger = Union[logging.Logger, BaseLogger] 92 | 93 | 94 | class NoOpLogger(BaseLogger): 95 | """ Class providing log method which logs nothing. """ 96 | 97 | def __init__(self) -> None: 98 | self.logger = reset_logger( 99 | name='.'.join([__name__, self.__class__.__name__]), level=logging.NOTSET, handler=logging.NullHandler(), 100 | ) 101 | 102 | 103 | class SimpleLogger(BaseLogger): 104 | """ Class providing log method which logs to stdout. """ 105 | 106 | def __init__(self, min_level: int = enums.LogLevels.INFO): 107 | self.level = min_level 108 | self.logger = reset_logger(name='.'.join([__name__, self.__class__.__name__]), level=min_level) 109 | 110 | def log(self, log_level: int, message: object) -> None: # type: ignore[override] 111 | # Log a deprecation/runtime warning. 112 | # Clients should be using standard loggers instead of this wrapper. 113 | warning = f'{self.__class__} is deprecated. Please use standard python loggers.' 114 | warnings.warn(warning, DeprecationWarning) 115 | 116 | # Log the message. 117 | self.logger.log(log_level, message) 118 | 119 | 120 | def adapt_logger(logger: Logger) -> Logger: 121 | """ 122 | Adapt our custom logger.BaseLogger object into a standard logging.Logger object. 123 | 124 | Adaptations are: 125 | - NoOpLogger turns into a logger with a single NullHandler. 126 | - SimpleLogger turns into a logger with a StreamHandler and level. 127 | 128 | Args: 129 | logger: Possibly a logger.BaseLogger, or a standard python logging.Logger. 130 | 131 | Returns: a standard python logging.Logger. 132 | 133 | """ 134 | if isinstance(logger, logging.Logger): 135 | return logger 136 | 137 | # Use the standard python logger created by these classes. 138 | if isinstance(logger, (SimpleLogger, NoOpLogger)): 139 | return logger.logger 140 | 141 | # Otherwise, return whatever we were given because we can't adapt. 142 | return logger 143 | -------------------------------------------------------------------------------- /optimizely/event/user_event_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, 2021-2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import TYPE_CHECKING, Optional 16 | from optimizely.helpers.event_tag_utils import EventTags 17 | from . import event_factory 18 | from . import user_event 19 | from optimizely.helpers import enums 20 | 21 | 22 | if TYPE_CHECKING: 23 | # prevent circular dependenacy by skipping import at runtime 24 | from optimizely.optimizely_user_context import UserAttributes 25 | from optimizely.project_config import ProjectConfig 26 | from optimizely.entities import Experiment, Variation 27 | 28 | 29 | class UserEventFactory: 30 | """ UserEventFactory builds impression and conversion events from a given UserEvent. """ 31 | 32 | @classmethod 33 | def create_impression_event( 34 | cls, 35 | project_config: ProjectConfig, 36 | activated_experiment: Experiment, 37 | variation_id: Optional[str], 38 | flag_key: str, 39 | rule_key: str, 40 | rule_type: str, 41 | enabled: bool, 42 | user_id: str, 43 | user_attributes: Optional[UserAttributes], 44 | cmab_uuid: Optional[str] 45 | ) -> Optional[user_event.ImpressionEvent]: 46 | """ Create impression Event to be sent to the logging endpoint. 47 | 48 | Args: 49 | project_config: Instance of ProjectConfig. 50 | experiment: Experiment for which impression needs to be recorded. 51 | variation_id: ID for variation which would be presented to user. 52 | flag_key: key for a feature flag. 53 | rule_key: key for an experiment. 54 | rule_type: type for the source. 55 | enabled: boolean representing if feature is enabled 56 | user_id: ID for user. 57 | user_attributes: Dict representing user attributes and values which need to be recorded. 58 | 59 | Returns: 60 | Event object encapsulating the impression event. None if: 61 | - activated_experiment is None. 62 | """ 63 | 64 | if not activated_experiment and rule_type is not enums.DecisionSources.ROLLOUT: 65 | return None 66 | 67 | variation: Optional[Variation] = None 68 | experiment_id = None 69 | if activated_experiment: 70 | experiment_id = activated_experiment.id 71 | 72 | if variation_id and flag_key: 73 | # need this condition when we send events involving forced decisions 74 | # (F-to-D or E-to-D with any ruleKey/variationKey combinations) 75 | variation = project_config.get_flag_variation(flag_key, 'id', variation_id) 76 | elif variation_id and experiment_id: 77 | variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) 78 | 79 | event_context = user_event.EventContext( 80 | project_config.account_id, 81 | project_config.project_id, 82 | project_config.revision, 83 | project_config.anonymize_ip, 84 | project_config.region 85 | ) 86 | 87 | return user_event.ImpressionEvent( 88 | event_context, 89 | user_id, 90 | activated_experiment, 91 | event_factory.EventFactory.build_attribute_list(user_attributes, project_config), 92 | variation, 93 | flag_key, 94 | rule_key, 95 | rule_type, 96 | enabled, 97 | project_config.get_bot_filtering_value(), 98 | cmab_uuid, 99 | ) 100 | 101 | @classmethod 102 | def create_conversion_event( 103 | cls, 104 | project_config: ProjectConfig, 105 | event_key: str, 106 | user_id: str, 107 | user_attributes: Optional[UserAttributes], 108 | event_tags: Optional[EventTags] 109 | ) -> Optional[user_event.ConversionEvent]: 110 | """ Create conversion Event to be sent to the logging endpoint. 111 | 112 | Args: 113 | project_config: Instance of ProjectConfig. 114 | event_key: Key representing the event which needs to be recorded. 115 | user_id: ID for user. 116 | user_attributes: Dict representing user attributes and values. 117 | event_tags: Dict representing metadata associated with the event. 118 | 119 | Returns: 120 | Event object encapsulating the conversion event. 121 | """ 122 | 123 | event_context = user_event.EventContext( 124 | project_config.account_id, 125 | project_config.project_id, 126 | project_config.revision, 127 | project_config.anonymize_ip, 128 | project_config.region 129 | ) 130 | 131 | return user_event.ConversionEvent( 132 | event_context, 133 | project_config.get_event(event_key), 134 | user_id, 135 | event_factory.EventFactory.build_attribute_list(user_attributes, project_config), 136 | event_tags, 137 | project_config.get_bot_filtering_value(), 138 | ) 139 | -------------------------------------------------------------------------------- /optimizely/helpers/event_tag_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import TYPE_CHECKING, Any, Optional, NewType, Dict 16 | from . import enums 17 | import math 18 | import numbers 19 | from sys import version_info 20 | 21 | if version_info < (3, 8): 22 | from typing_extensions import Final 23 | else: 24 | from typing import Final 25 | 26 | 27 | if TYPE_CHECKING: 28 | # prevent circular dependenacy by skipping import at runtime 29 | from optimizely.logger import Logger 30 | 31 | 32 | REVENUE_METRIC_TYPE: Final = 'revenue' 33 | NUMERIC_METRIC_TYPE: Final = 'value' 34 | 35 | # type for tracking event tags (essentially a sub-type of dict) 36 | EventTags = NewType('EventTags', Dict[str, Any]) 37 | 38 | 39 | def get_revenue_value(event_tags: Optional[EventTags]) -> Optional[numbers.Integral]: 40 | if event_tags is None: 41 | return None 42 | 43 | if not isinstance(event_tags, dict): 44 | return None 45 | 46 | if REVENUE_METRIC_TYPE not in event_tags: 47 | return None 48 | 49 | raw_value = event_tags[REVENUE_METRIC_TYPE] 50 | 51 | if isinstance(raw_value, bool): 52 | return None 53 | 54 | if not isinstance(raw_value, numbers.Integral): 55 | return None 56 | 57 | return raw_value 58 | 59 | 60 | def get_numeric_value(event_tags: Optional[EventTags], logger: Optional[Logger] = None) -> Optional[float]: 61 | """ 62 | A smart getter of the numeric value from the event tags. 63 | 64 | Args: 65 | event_tags: A dictionary of event tags. 66 | logger: Optional logger. 67 | 68 | Returns: 69 | A float numeric metric value is returned when the provided numeric 70 | metric value is in the following format: 71 | - A string (properly formatted, e.g., no commas) 72 | - An integer 73 | - A float or double 74 | None is returned when the provided numeric metric values is in 75 | the following format: 76 | - None 77 | - A boolean 78 | - inf, -inf, nan 79 | - A string not properly formatted (e.g., '1,234') 80 | - Any values that cannot be cast to a float (e.g., an array or dictionary) 81 | """ 82 | 83 | logger_message_debug = None 84 | numeric_metric_value: Optional[float] = None 85 | 86 | if event_tags is None: 87 | return numeric_metric_value 88 | elif not isinstance(event_tags, dict): 89 | if logger: 90 | logger.log(enums.LogLevels.ERROR, 'Event tags is not a dictionary.') 91 | return numeric_metric_value 92 | elif NUMERIC_METRIC_TYPE not in event_tags: 93 | return numeric_metric_value 94 | else: 95 | numeric_metric_value = event_tags[NUMERIC_METRIC_TYPE] 96 | try: 97 | if isinstance(numeric_metric_value, (numbers.Integral, float, str)): 98 | # Attempt to convert the numeric metric value to a float 99 | # (if it isn't already a float). 100 | cast_numeric_metric_value = float(numeric_metric_value) 101 | 102 | # If not a float after casting, then make everything else a None. 103 | # Other potential values are nan, inf, and -inf. 104 | if not isinstance(cast_numeric_metric_value, float) or \ 105 | math.isnan(cast_numeric_metric_value) or \ 106 | math.isinf(cast_numeric_metric_value): 107 | logger_message_debug = f'Provided numeric value {numeric_metric_value} is in an invalid format.' 108 | numeric_metric_value = None 109 | else: 110 | # Handle booleans as a special case. 111 | # They are treated like an integer in the cast, but we do not want to cast this. 112 | if isinstance(numeric_metric_value, bool): 113 | logger_message_debug = 'Provided numeric value is a boolean, which is an invalid format.' 114 | numeric_metric_value = None 115 | else: 116 | numeric_metric_value = cast_numeric_metric_value 117 | else: 118 | logger_message_debug = 'Numeric metric value is not in integer, float, or string form.' 119 | numeric_metric_value = None 120 | 121 | except ValueError: 122 | logger_message_debug = 'Value error while casting numeric metric value to a float.' 123 | numeric_metric_value = None 124 | 125 | # Log all potential debug messages while converting the numeric value to a float. 126 | if logger and logger_message_debug: 127 | logger.log(enums.LogLevels.DEBUG, logger_message_debug) 128 | 129 | # Log the final numeric metric value 130 | if numeric_metric_value is not None: 131 | if logger: 132 | logger.log( 133 | enums.LogLevels.INFO, 134 | f'The numeric metric value {numeric_metric_value} will be sent to results.' 135 | ) 136 | else: 137 | if logger: 138 | logger.log( 139 | enums.LogLevels.WARNING, 140 | f'The provided numeric metric value {numeric_metric_value}' 141 | ' is in an invalid format and will not be sent to results.' 142 | ) 143 | 144 | return numeric_metric_value 145 | -------------------------------------------------------------------------------- /tests/test_event_payload.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from optimizely import version 15 | from optimizely.event import payload 16 | from . import base 17 | 18 | 19 | class EventPayloadTest(base.BaseTest): 20 | def test_impression_event_equals_serialized_payload(self): 21 | expected_params = { 22 | 'account_id': '12001', 23 | 'project_id': '111001', 24 | 'visitors': [ 25 | { 26 | 'visitor_id': 'test_user', 27 | 'attributes': [ 28 | {'type': 'custom', 'value': 'test_value', 'entity_id': '111094', 'key': 'test_attribute'} 29 | ], 30 | 'snapshots': [ 31 | { 32 | 'decisions': [ 33 | {'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182', 34 | 'metadata': {'flag_key': 'flag_key', 35 | 'rule_key': 'rule_key', 36 | 'rule_type': 'experiment', 37 | 'variation_key': 'variation', 38 | 'enabled': False}, 39 | } 40 | ], 41 | 'events': [ 42 | { 43 | 'timestamp': 42123, 44 | 'entity_id': '111182', 45 | 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', 46 | 'key': 'campaign_activated', 47 | } 48 | ], 49 | } 50 | ], 51 | } 52 | ], 53 | 'client_name': 'python-sdk', 54 | 'client_version': version.__version__, 55 | 'enrich_decisions': True, 56 | 'anonymize_ip': False, 57 | 'revision': '42', 58 | } 59 | 60 | batch = payload.EventBatch('12001', '111001', '42', 'python-sdk', version.__version__, False, True) 61 | visitor_attr = payload.VisitorAttribute('111094', 'test_attribute', 'custom', 'test_value') 62 | event = payload.SnapshotEvent('111182', 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', 'campaign_activated', 42123,) 63 | metadata = payload.Metadata('flag_key', 'rule_key', 'experiment', 'variation', False) 64 | event_decision = payload.Decision('111182', '111127', '111129', metadata) 65 | 66 | snapshots = payload.Snapshot([event], [event_decision]) 67 | user = payload.Visitor([snapshots], [visitor_attr], 'test_user') 68 | 69 | batch.visitors = [user] 70 | 71 | self.assertEqual(batch, expected_params) 72 | 73 | def test_conversion_event_equals_serialized_payload(self): 74 | expected_params = { 75 | 'account_id': '12001', 76 | 'project_id': '111001', 77 | 'visitors': [ 78 | { 79 | 'visitor_id': 'test_user', 80 | 'attributes': [ 81 | {'type': 'custom', 'value': 'test_value', 'entity_id': '111094', 'key': 'test_attribute'}, 82 | {'type': 'custom', 'value': 'test_value2', 'entity_id': '111095', 'key': 'test_attribute2'}, 83 | ], 84 | 'snapshots': [ 85 | { 86 | 'events': [ 87 | { 88 | 'timestamp': 42123, 89 | 'entity_id': '111182', 90 | 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', 91 | 'key': 'campaign_activated', 92 | 'revenue': 4200, 93 | 'tags': {'non-revenue': 'abc', 'revenue': 4200, 'value': 1.234}, 94 | 'value': 1.234, 95 | } 96 | ] 97 | } 98 | ], 99 | } 100 | ], 101 | 'client_name': 'python-sdk', 102 | 'client_version': version.__version__, 103 | 'enrich_decisions': True, 104 | 'anonymize_ip': False, 105 | 'revision': '42', 106 | } 107 | 108 | batch = payload.EventBatch('12001', '111001', '42', 'python-sdk', version.__version__, False, True) 109 | visitor_attr_1 = payload.VisitorAttribute('111094', 'test_attribute', 'custom', 'test_value') 110 | visitor_attr_2 = payload.VisitorAttribute('111095', 'test_attribute2', 'custom', 'test_value2') 111 | event = payload.SnapshotEvent( 112 | '111182', 113 | 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', 114 | 'campaign_activated', 115 | 42123, 116 | 4200, 117 | 1.234, 118 | {'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'}, 119 | ) 120 | 121 | snapshots = payload.Snapshot([event]) 122 | user = payload.Visitor([snapshots], [visitor_attr_1, visitor_attr_2], 'test_user') 123 | 124 | batch.visitors = [user] 125 | 126 | self.assertEqual(batch, expected_params) 127 | -------------------------------------------------------------------------------- /optimizely/odp/odp_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # https://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | 16 | from typing import Optional, Any 17 | 18 | from optimizely import logger as optimizely_logger 19 | from optimizely.helpers.enums import Errors, OdpManagerConfig, OdpSegmentsCacheConfig 20 | from optimizely.helpers.validator import are_odp_data_types_valid 21 | from optimizely.odp.lru_cache import OptimizelySegmentsCache, LRUCache 22 | from optimizely.odp.odp_config import OdpConfig, OdpConfigState 23 | from optimizely.odp.odp_event_manager import OdpEventManager 24 | from optimizely.odp.odp_segment_manager import OdpSegmentManager 25 | 26 | 27 | class OdpManager: 28 | """Orchestrates segment manager, event manager and odp config.""" 29 | 30 | def __init__( 31 | self, 32 | disable: bool, 33 | segments_cache: Optional[OptimizelySegmentsCache] = None, 34 | segment_manager: Optional[OdpSegmentManager] = None, 35 | event_manager: Optional[OdpEventManager] = None, 36 | fetch_segments_timeout: Optional[int] = None, 37 | odp_event_timeout: Optional[int] = None, 38 | odp_flush_interval: Optional[int] = None, 39 | logger: Optional[optimizely_logger.Logger] = None 40 | ) -> None: 41 | 42 | self.enabled = not disable 43 | self.odp_config = OdpConfig() 44 | self.logger = logger or optimizely_logger.NoOpLogger() 45 | 46 | self.segment_manager = segment_manager 47 | self.event_manager = event_manager 48 | self.fetch_segments_timeout = fetch_segments_timeout 49 | 50 | if not self.enabled: 51 | self.logger.info('ODP is disabled.') 52 | return 53 | 54 | if not self.segment_manager: 55 | if not segments_cache: 56 | segments_cache = LRUCache( 57 | OdpSegmentsCacheConfig.DEFAULT_CAPACITY, 58 | OdpSegmentsCacheConfig.DEFAULT_TIMEOUT_SECS 59 | ) 60 | self.segment_manager = OdpSegmentManager(segments_cache, logger=self.logger, timeout=fetch_segments_timeout) 61 | 62 | self.event_manager = self.event_manager or OdpEventManager(self.logger, request_timeout=odp_event_timeout, 63 | flush_interval=odp_flush_interval) 64 | self.segment_manager.odp_config = self.odp_config 65 | 66 | def fetch_qualified_segments(self, user_id: str, options: list[str]) -> Optional[list[str]]: 67 | if not self.enabled or not self.segment_manager: 68 | self.logger.error(Errors.ODP_NOT_ENABLED) 69 | return None 70 | 71 | user_key = OdpManagerConfig.KEY_FOR_USER_ID 72 | user_value = user_id 73 | 74 | return self.segment_manager.fetch_qualified_segments(user_key, user_value, options) 75 | 76 | def identify_user(self, user_id: str) -> None: 77 | if not self.enabled or not self.event_manager: 78 | self.logger.debug('ODP identify event is not dispatched (ODP disabled).') 79 | return 80 | if self.odp_config.odp_state() == OdpConfigState.NOT_INTEGRATED: 81 | self.logger.debug('ODP identify event is not dispatched (ODP not integrated).') 82 | return 83 | 84 | self.event_manager.identify_user(user_id) 85 | 86 | def send_event(self, type: str, action: str, identifiers: dict[str, str], data: dict[str, Any]) -> None: 87 | """ 88 | Send an event to the ODP server. 89 | 90 | Args: 91 | type: The event type. 92 | action: The event action name. 93 | identifiers: A dictionary for identifiers. 94 | data: A dictionary for associated data. The default event data will be added to this data 95 | before sending to the ODP server. 96 | """ 97 | if not self.enabled or not self.event_manager: 98 | self.logger.error(Errors.ODP_NOT_ENABLED) 99 | return 100 | 101 | if self.odp_config.odp_state() == OdpConfigState.NOT_INTEGRATED: 102 | self.logger.error(Errors.ODP_NOT_INTEGRATED) 103 | return 104 | 105 | if not are_odp_data_types_valid(data): 106 | self.logger.error(Errors.ODP_INVALID_DATA) 107 | return 108 | 109 | self.event_manager.send_event(type, action, identifiers, data) 110 | 111 | def update_odp_config(self, api_key: Optional[str], api_host: Optional[str], 112 | segments_to_check: list[str]) -> None: 113 | if not self.enabled: 114 | return 115 | 116 | config_changed = self.odp_config.update(api_key, api_host, segments_to_check) 117 | if not config_changed: 118 | self.logger.debug('Odp config was not changed.') 119 | return 120 | 121 | # reset segments cache when odp integration or segments to check are changed 122 | if self.segment_manager: 123 | self.segment_manager.reset() 124 | 125 | if not self.event_manager: 126 | return 127 | 128 | if self.event_manager.is_running: 129 | self.event_manager.update_config() 130 | elif self.odp_config.odp_state() == OdpConfigState.INTEGRATED: 131 | self.event_manager.start(self.odp_config) 132 | 133 | def close(self) -> None: 134 | if self.enabled and self.event_manager: 135 | self.event_manager.stop() 136 | -------------------------------------------------------------------------------- /tests/test_user_profile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import unittest 15 | 16 | from optimizely import user_profile 17 | from unittest import mock 18 | 19 | 20 | class UserProfileTest(unittest.TestCase): 21 | def setUp(self): 22 | user_id = 'test_user' 23 | experiment_bucket_map = {'199912': {'variation_id': '14512525'}} 24 | 25 | self.profile = user_profile.UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) 26 | 27 | def test_get_variation_for_experiment__decision_exists(self): 28 | """ Test that variation ID is retrieved correctly if a decision exists in the experiment bucket map. """ 29 | 30 | self.assertEqual('14512525', self.profile.get_variation_for_experiment('199912')) 31 | 32 | def test_get_variation_for_experiment__no_decision_exists(self): 33 | """ Test that None is returned if no decision exists in the experiment bucket map. """ 34 | 35 | self.assertIsNone(self.profile.get_variation_for_experiment('199924')) 36 | 37 | def test_set_variation_for_experiment__no_previous_decision(self): 38 | """ Test that decision for new experiment/variation is stored correctly. """ 39 | 40 | self.profile.save_variation_for_experiment('1993412', '118822') 41 | self.assertEqual( 42 | {'199912': {'variation_id': '14512525'}, '1993412': {'variation_id': '118822'}}, 43 | self.profile.experiment_bucket_map, 44 | ) 45 | 46 | def test_set_variation_for_experiment__previous_decision_available(self): 47 | """ Test that decision for is updated correctly if new experiment/variation combination is available. """ 48 | 49 | self.profile.save_variation_for_experiment('199912', '1224525') 50 | self.assertEqual({'199912': {'variation_id': '1224525'}}, self.profile.experiment_bucket_map) 51 | 52 | 53 | class UserProfileServiceTest(unittest.TestCase): 54 | def test_lookup(self): 55 | """ Test that lookup returns user profile in expected format. """ 56 | 57 | user_profile_service = user_profile.UserProfileService() 58 | self.assertEqual( 59 | {'user_id': 'test_user', 'experiment_bucket_map': {}}, user_profile_service.lookup('test_user'), 60 | ) 61 | 62 | def test_save(self): 63 | """ Test that nothing happens on calling save. """ 64 | 65 | user_profile_service = user_profile.UserProfileService() 66 | self.assertIsNone(user_profile_service.save({'user_id': 'test_user', 'experiment_bucket_map': {}})) 67 | 68 | 69 | class UserProfileTrackerTest(unittest.TestCase): 70 | def test_load_user_profile_failure(self): 71 | """Test that load_user_profile handles exceptions gracefully.""" 72 | mock_user_profile_service = mock.MagicMock() 73 | mock_logger = mock.MagicMock() 74 | 75 | user_profile_tracker = user_profile.UserProfileTracker( 76 | user_id="test_user", 77 | user_profile_service=mock_user_profile_service, 78 | logger=mock_logger 79 | ) 80 | mock_user_profile_service.lookup.side_effect = Exception("Lookup failure") 81 | 82 | user_profile_tracker.load_user_profile() 83 | 84 | # Verify that the logger recorded the exception 85 | mock_logger.exception.assert_called_once_with( 86 | 'Unable to retrieve user profile for user "test_user" as lookup failed.' 87 | ) 88 | 89 | # Verify that the user profile is reset to an empty profile 90 | self.assertEqual(user_profile_tracker.user_profile.user_id, "test_user") 91 | self.assertEqual(user_profile_tracker.user_profile.experiment_bucket_map, {}) 92 | 93 | def test_load_user_profile__user_profile_invalid(self): 94 | """Test that load_user_profile handles an invalid user profile format.""" 95 | mock_user_profile_service = mock.MagicMock() 96 | mock_logger = mock.MagicMock() 97 | 98 | user_profile_tracker = user_profile.UserProfileTracker( 99 | user_id="test_user", 100 | user_profile_service=mock_user_profile_service, 101 | logger=mock_logger 102 | ) 103 | 104 | mock_user_profile_service.lookup.return_value = {"invalid_key": "value"} 105 | 106 | reasons = [] 107 | user_profile_tracker.load_user_profile(reasons=reasons) 108 | 109 | # Verify that the logger recorded a warning for the missing keys 110 | missing_keys_message = "User profile is missing keys: user_id, experiment_bucket_map" 111 | self.assertIn(missing_keys_message, reasons) 112 | 113 | # Ensure the logger logs the invalid format 114 | mock_logger.info.assert_not_called() 115 | self.assertEqual(user_profile_tracker.user_profile.user_id, "test_user") 116 | self.assertEqual(user_profile_tracker.user_profile.experiment_bucket_map, {}) 117 | 118 | # Verify the reasons list was updated 119 | self.assertIn(missing_keys_message, reasons) 120 | 121 | def test_save_user_profile_failure(self): 122 | """Test that save_user_profile handles exceptions gracefully.""" 123 | mock_user_profile_service = mock.MagicMock() 124 | mock_logger = mock.MagicMock() 125 | 126 | user_profile_tracker = user_profile.UserProfileTracker( 127 | user_id="test_user", 128 | user_profile_service=mock_user_profile_service, 129 | logger=mock_logger 130 | ) 131 | 132 | user_profile_tracker.profile_updated = True 133 | mock_user_profile_service.save.side_effect = Exception("Save failure") 134 | 135 | user_profile_tracker.save_user_profile() 136 | 137 | mock_logger.warning.assert_called_once_with( 138 | 'Failed to save user profile of user "test_user" for exception:Save failure".' 139 | ) 140 | -------------------------------------------------------------------------------- /optimizely/notification_center.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import Any, Callable, Optional 16 | from .helpers import enums 17 | from . import logger as optimizely_logger 18 | from sys import version_info 19 | 20 | if version_info < (3, 8): 21 | from typing_extensions import Final 22 | else: 23 | from typing import Final 24 | 25 | 26 | NOTIFICATION_TYPES: Final = tuple( 27 | getattr(enums.NotificationTypes, attr) for attr in dir(enums.NotificationTypes) if not attr.startswith('__') 28 | ) 29 | 30 | 31 | class NotificationCenter: 32 | """ Class encapsulating methods to manage notifications and their listeners. 33 | The enums.NotificationTypes includes predefined notifications.""" 34 | 35 | def __init__(self, logger: Optional[optimizely_logger.Logger] = None): 36 | self.listener_id = 1 37 | self.notification_listeners: dict[str, list[tuple[int, Callable[..., None]]]] = {} 38 | for notification_type in NOTIFICATION_TYPES: 39 | self.notification_listeners[notification_type] = [] 40 | self.logger = optimizely_logger.adapt_logger(logger or optimizely_logger.NoOpLogger()) 41 | 42 | def add_notification_listener(self, notification_type: str, notification_callback: Callable[..., None]) -> int: 43 | """ Add a notification callback to the notification center for a given notification type. 44 | 45 | Args: 46 | notification_type: A string representing the notification type from helpers.enums.NotificationTypes 47 | notification_callback: Closure of function to call when event is triggered. 48 | 49 | Returns: 50 | Integer notification ID used to remove the notification or 51 | -1 if the notification listener has already been added or 52 | if the notification type is invalid. 53 | """ 54 | 55 | if notification_type not in NOTIFICATION_TYPES: 56 | self.logger.error(f'Invalid notification_type: {notification_type} provided. Not adding listener.') 57 | return -1 58 | 59 | for _, listener in self.notification_listeners[notification_type]: 60 | if listener == notification_callback: 61 | self.logger.error('Listener has already been added. Not adding it again.') 62 | return -1 63 | 64 | self.notification_listeners[notification_type].append((self.listener_id, notification_callback)) 65 | current_listener_id = self.listener_id 66 | self.listener_id += 1 67 | 68 | return current_listener_id 69 | 70 | def remove_notification_listener(self, notification_id: int) -> bool: 71 | """ Remove a previously added notification callback. 72 | 73 | Args: 74 | notification_id: The numeric id passed back from add_notification_listener 75 | 76 | Returns: 77 | The function returns boolean true if found and removed, false otherwise. 78 | """ 79 | 80 | for listener in self.notification_listeners.values(): 81 | listener_to_remove = list(filter(lambda tup: tup[0] == notification_id, listener)) 82 | if len(listener_to_remove) > 0: 83 | listener.remove(listener_to_remove[0]) 84 | return True 85 | 86 | return False 87 | 88 | def clear_notification_listeners(self, notification_type: str) -> None: 89 | """ Remove notification listeners for a certain notification type. 90 | 91 | Args: 92 | notification_type: String denoting notification type. 93 | """ 94 | 95 | if notification_type not in NOTIFICATION_TYPES: 96 | self.logger.error( 97 | f'Invalid notification_type: {notification_type} provided. Not removing any listener.' 98 | ) 99 | self.notification_listeners[notification_type] = [] 100 | 101 | def clear_notifications(self, notification_type: str) -> None: 102 | """ (DEPRECATED since 3.2.0, use clear_notification_listeners) 103 | Remove notification listeners for a certain notification type. 104 | 105 | Args: 106 | notification_type: key to the list of notifications .helpers.enums.NotificationTypes 107 | """ 108 | self.clear_notification_listeners(notification_type) 109 | 110 | def clear_all_notification_listeners(self) -> None: 111 | """ Remove all notification listeners. """ 112 | for notification_type in self.notification_listeners.keys(): 113 | self.clear_notification_listeners(notification_type) 114 | 115 | def clear_all_notifications(self) -> None: 116 | """ (DEPRECATED since 3.2.0, use clear_all_notification_listeners) 117 | Remove all notification listeners. """ 118 | self.clear_all_notification_listeners() 119 | 120 | def send_notifications(self, notification_type: str, *args: Any) -> None: 121 | """ Fires off the notification for the specific event. Uses var args to pass in a 122 | arbitrary list of parameter according to which notification type was fired. 123 | 124 | Args: 125 | notification_type: Type of notification to fire (String from .helpers.enums.NotificationTypes) 126 | args: Variable list of arguments to the callback. 127 | """ 128 | 129 | if notification_type not in NOTIFICATION_TYPES: 130 | self.logger.error( 131 | f'Invalid notification_type: {notification_type} provided. ' 'Not triggering any notification.' 132 | ) 133 | return 134 | 135 | if notification_type in self.notification_listeners: 136 | for notification_id, callback in self.notification_listeners[notification_type]: 137 | try: 138 | callback(*args) 139 | except: 140 | self.logger.exception( 141 | f'Unknown problem when sending "{notification_type}" type notification.' 142 | ) 143 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2018, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | import logging 14 | import unittest 15 | import uuid 16 | 17 | from unittest import mock 18 | 19 | from optimizely import logger as _logger 20 | 21 | 22 | class SimpleLoggerTests(unittest.TestCase): 23 | def test_log__deprecation_warning(self): 24 | """Test that SimpleLogger now outputs a deprecation warning on ``.log`` calls.""" 25 | simple_logger = _logger.SimpleLogger() 26 | actual_log_patch = mock.patch.object(simple_logger, 'logger') 27 | warnings_patch = mock.patch('warnings.warn') 28 | with warnings_patch as patched_warnings, actual_log_patch as log_patch: 29 | simple_logger.log(logging.INFO, 'Message') 30 | 31 | msg = " is deprecated. " "Please use standard python loggers." 32 | patched_warnings.assert_called_once_with(msg, DeprecationWarning) 33 | log_patch.log.assert_called_once_with(logging.INFO, 'Message') 34 | 35 | 36 | class AdaptLoggerTests(unittest.TestCase): 37 | def test_adapt_logger__standard_logger(self): 38 | """Test that adapt_logger does nothing to standard python loggers.""" 39 | logger_name = str(uuid.uuid4()) 40 | standard_logger = logging.getLogger(logger_name) 41 | adapted = _logger.adapt_logger(standard_logger) 42 | self.assertIs(standard_logger, adapted) 43 | 44 | def test_adapt_logger__simple(self): 45 | """Test that adapt_logger returns a standard python logger from a SimpleLogger.""" 46 | simple_logger = _logger.SimpleLogger() 47 | standard_logger = _logger.adapt_logger(simple_logger) 48 | 49 | # adapt_logger knows about the loggers attached to this class. 50 | self.assertIs(simple_logger.logger, standard_logger) 51 | 52 | # Verify the standard properties of the logger. 53 | self.assertIsInstance(standard_logger, logging.Logger) 54 | self.assertEqual('optimizely.logger.SimpleLogger', standard_logger.name) 55 | self.assertEqual(logging.INFO, standard_logger.level) 56 | 57 | # Should have a single StreamHandler with our default formatting. 58 | self.assertEqual(1, len(standard_logger.handlers)) 59 | handler = standard_logger.handlers[0] 60 | self.assertIsInstance(handler, logging.StreamHandler) 61 | self.assertEqual( 62 | '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s', handler.formatter._fmt, 63 | ) 64 | 65 | def test_adapt_logger__noop(self): 66 | """Test that adapt_logger returns a standard python logger from a NoOpLogger.""" 67 | noop_logger = _logger.NoOpLogger() 68 | standard_logger = _logger.adapt_logger(noop_logger) 69 | 70 | # adapt_logger knows about the loggers attached to this class. 71 | self.assertIs(noop_logger.logger, standard_logger) 72 | 73 | # Verify properties of the logger 74 | self.assertIsInstance(standard_logger, logging.Logger) 75 | self.assertEqual('optimizely.logger.NoOpLogger', standard_logger.name) 76 | self.assertEqual(logging.NOTSET, standard_logger.level) 77 | 78 | # Should have a single NullHandler (with a default formatter). 79 | self.assertEqual(1, len(standard_logger.handlers)) 80 | handler = standard_logger.handlers[0] 81 | self.assertIsInstance(handler, logging.NullHandler) 82 | self.assertEqual( 83 | '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s', handler.formatter._fmt, 84 | ) 85 | 86 | def test_adapt_logger__unknown(self): 87 | """Test that adapt_logger gives back things it can't adapt.""" 88 | obj = object() 89 | value = _logger.adapt_logger(obj) 90 | self.assertIs(obj, value) 91 | 92 | 93 | class GetLoggerTests(unittest.TestCase): 94 | def test_reset_logger(self): 95 | """Test that reset_logger gives back a standard python logger with defaults.""" 96 | logger_name = str(uuid.uuid4()) 97 | logger = _logger.reset_logger(logger_name) 98 | self.assertEqual(logger_name, logger.name) 99 | self.assertEqual(1, len(logger.handlers)) 100 | handler = logger.handlers[0] 101 | self.assertIsInstance(handler, logging.StreamHandler) 102 | self.assertEqual( 103 | '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s', handler.formatter._fmt, 104 | ) 105 | 106 | def test_reset_logger__replaces_handlers(self): 107 | """Test that reset_logger replaces existing handlers with a StreamHandler.""" 108 | logger_name = f'test-logger-{uuid.uuid4()}' 109 | logger = logging.getLogger(logger_name) 110 | logger.handlers = [logging.StreamHandler() for _ in range(10)] 111 | 112 | reset_logger = _logger.reset_logger(logger_name) 113 | self.assertEqual(1, len(reset_logger.handlers)) 114 | 115 | handler = reset_logger.handlers[0] 116 | self.assertIsInstance(handler, logging.StreamHandler) 117 | self.assertEqual( 118 | '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s', handler.formatter._fmt, 119 | ) 120 | 121 | def test_reset_logger__with_handler__existing(self): 122 | """Test that reset_logger deals with provided handlers correctly.""" 123 | existing_handler = logging.NullHandler() 124 | logger_name = f'test-logger-{uuid.uuid4()}' 125 | reset_logger = _logger.reset_logger(logger_name, handler=existing_handler) 126 | self.assertEqual(1, len(reset_logger.handlers)) 127 | 128 | handler = reset_logger.handlers[0] 129 | self.assertIs(existing_handler, handler) 130 | self.assertEqual( 131 | '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s', handler.formatter._fmt, 132 | ) 133 | 134 | def test_reset_logger__with_level(self): 135 | """Test that reset_logger sets log levels correctly.""" 136 | logger_name = f'test-logger-{uuid.uuid4()}' 137 | reset_logger = _logger.reset_logger(logger_name, level=logging.DEBUG) 138 | self.assertEqual(logging.DEBUG, reset_logger.level) 139 | -------------------------------------------------------------------------------- /optimizely/entities.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | from __future__ import annotations 14 | from typing import TYPE_CHECKING, Any, Optional, Sequence 15 | from sys import version_info 16 | 17 | if version_info < (3, 8): 18 | from typing_extensions import Final 19 | else: 20 | from typing import Final 21 | 22 | 23 | if TYPE_CHECKING: 24 | # prevent circular dependenacy by skipping import at runtime 25 | from .helpers.types import ExperimentDict, TrafficAllocation, VariableDict, VariationDict, CmabDict 26 | 27 | 28 | class BaseEntity: 29 | def __eq__(self, other: object) -> bool: 30 | return self.__dict__ == other.__dict__ 31 | 32 | 33 | class Attribute(BaseEntity): 34 | def __init__(self, id: str, key: str, **kwargs: Any): 35 | self.id = id 36 | self.key = key 37 | 38 | 39 | class Audience(BaseEntity): 40 | def __init__( 41 | self, 42 | id: str, 43 | name: str, 44 | conditions: str, 45 | conditionStructure: Optional[list[str | list[str]]] = None, 46 | conditionList: Optional[list[str | list[str]]] = None, 47 | **kwargs: Any 48 | ): 49 | self.id = id 50 | self.name = name 51 | self.conditions = conditions 52 | self.conditionStructure = conditionStructure 53 | self.conditionList = conditionList 54 | 55 | def get_segments(self) -> list[str]: 56 | """ Extract all audience segments used in the this audience's conditions. 57 | 58 | Returns: 59 | List of segment names. 60 | """ 61 | if not self.conditionList: 62 | return [] 63 | return list({c[1] for c in self.conditionList if c[3] == 'qualified'}) 64 | 65 | 66 | class Event(BaseEntity): 67 | def __init__(self, id: str, key: str, experimentIds: list[str], **kwargs: Any): 68 | self.id = id 69 | self.key = key 70 | self.experimentIds = experimentIds 71 | 72 | 73 | class Experiment(BaseEntity): 74 | def __init__( 75 | self, 76 | id: str, 77 | key: str, 78 | status: str, 79 | audienceIds: list[str], 80 | variations: list[VariationDict], 81 | forcedVariations: dict[str, str], 82 | trafficAllocation: list[TrafficAllocation], 83 | layerId: str, 84 | audienceConditions: Optional[Sequence[str | list[str]]] = None, 85 | groupId: Optional[str] = None, 86 | groupPolicy: Optional[str] = None, 87 | cmab: Optional[CmabDict] = None, 88 | **kwargs: Any 89 | ): 90 | self.id = id 91 | self.key = key 92 | self.status = status 93 | self.audienceIds = audienceIds 94 | self.audienceConditions = audienceConditions 95 | self.variations = variations 96 | self.forcedVariations = forcedVariations 97 | self.trafficAllocation = trafficAllocation 98 | self.layerId = layerId 99 | self.groupId = groupId 100 | self.groupPolicy = groupPolicy 101 | self.cmab = cmab 102 | 103 | def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]: 104 | """ Returns audienceConditions if present, otherwise audienceIds. """ 105 | return self.audienceConditions if self.audienceConditions is not None else self.audienceIds 106 | 107 | def __str__(self) -> str: 108 | return self.key 109 | 110 | @staticmethod 111 | def get_default() -> Experiment: 112 | """ returns an empty experiment object. """ 113 | experiment = Experiment( 114 | id='', 115 | key='', 116 | layerId='', 117 | status='', 118 | variations=[], 119 | trafficAllocation=[], 120 | audienceIds=[], 121 | audienceConditions=[], 122 | forcedVariations={} 123 | ) 124 | 125 | return experiment 126 | 127 | 128 | class FeatureFlag(BaseEntity): 129 | def __init__( 130 | self, id: str, key: str, experimentIds: list[str], rolloutId: str, 131 | variables: list[VariableDict], groupId: Optional[str] = None, **kwargs: Any 132 | ): 133 | self.id = id 134 | self.key = key 135 | self.experimentIds = experimentIds 136 | self.rolloutId = rolloutId 137 | self.variables: dict[str, Variable] = variables # type: ignore[assignment] 138 | self.groupId = groupId 139 | 140 | 141 | class Group(BaseEntity): 142 | def __init__( 143 | self, id: str, policy: str, experiments: list[Experiment], 144 | trafficAllocation: list[TrafficAllocation], **kwargs: Any 145 | ): 146 | self.id = id 147 | self.policy = policy 148 | self.experiments = experiments 149 | self.trafficAllocation = trafficAllocation 150 | 151 | 152 | class Layer(BaseEntity): 153 | """Layer acts as rollout.""" 154 | def __init__(self, id: str, experiments: list[ExperimentDict], **kwargs: Any): 155 | self.id = id 156 | self.experiments = experiments 157 | 158 | 159 | class Variable(BaseEntity): 160 | class Type: 161 | BOOLEAN: Final = 'boolean' 162 | DOUBLE: Final = 'double' 163 | INTEGER: Final = 'integer' 164 | JSON: Final = 'json' 165 | STRING: Final = 'string' 166 | 167 | def __init__(self, id: str, key: str, type: str, defaultValue: Any, **kwargs: Any): 168 | self.id = id 169 | self.key = key 170 | self.type = type 171 | self.defaultValue = defaultValue 172 | 173 | 174 | class Variation(BaseEntity): 175 | class VariableUsage(BaseEntity): 176 | def __init__(self, id: str, value: str, **kwargs: Any): 177 | self.id = id 178 | self.value = value 179 | 180 | def __init__( 181 | self, id: str, key: str, featureEnabled: bool = False, variables: Optional[list[Variable]] = None, **kwargs: Any 182 | ): 183 | self.id = id 184 | self.key = key 185 | self.featureEnabled = featureEnabled 186 | self.variables = variables or [] 187 | 188 | def __str__(self) -> str: 189 | return self.key 190 | 191 | 192 | class Integration(BaseEntity): 193 | def __init__(self, key: str, host: Optional[str] = None, publicKey: Optional[str] = None, **kwargs: Any): 194 | self.key = key 195 | self.host = host 196 | self.publicKey = publicKey 197 | -------------------------------------------------------------------------------- /optimizely/user_profile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import Any, Optional 16 | from sys import version_info 17 | from . import logger as _logging 18 | 19 | if version_info < (3, 8): 20 | from typing_extensions import Final 21 | else: 22 | from typing import Final, TYPE_CHECKING 23 | 24 | if TYPE_CHECKING: 25 | # prevent circular dependenacy by skipping import at runtime 26 | from .entities import Experiment, Variation 27 | from optimizely.error_handler import BaseErrorHandler 28 | 29 | 30 | class UserProfile: 31 | """ Class encapsulating information representing a user's profile. 32 | 33 | user_id: User's identifier. 34 | experiment_bucket_map: Dict mapping experiment ID to dict consisting of the 35 | variation ID identifying the variation for the user. 36 | """ 37 | 38 | USER_ID_KEY: Final = 'user_id' 39 | EXPERIMENT_BUCKET_MAP_KEY: Final = 'experiment_bucket_map' 40 | VARIATION_ID_KEY: Final = 'variation_id' 41 | 42 | def __init__( 43 | self, 44 | user_id: str, 45 | experiment_bucket_map: Optional[dict[str, dict[str, Optional[str]]]] = None, 46 | **kwargs: Any 47 | ): 48 | self.user_id = user_id 49 | self.experiment_bucket_map = experiment_bucket_map or {} 50 | 51 | def __eq__(self, other: object) -> bool: 52 | return self.__dict__ == other.__dict__ 53 | 54 | def get_variation_for_experiment(self, experiment_id: str) -> Optional[str]: 55 | """ Helper method to retrieve variation ID for given experiment. 56 | 57 | Args: 58 | experiment_id: ID for experiment for which variation needs to be looked up for. 59 | 60 | Returns: 61 | Variation ID corresponding to the experiment. None if no decision available. 62 | """ 63 | return self.experiment_bucket_map.get(experiment_id, {self.VARIATION_ID_KEY: None}).get(self.VARIATION_ID_KEY) 64 | 65 | def save_variation_for_experiment(self, experiment_id: str, variation_id: str) -> None: 66 | """ Helper method to save new experiment/variation as part of the user's profile. 67 | 68 | Args: 69 | experiment_id: ID for experiment for which the decision is to be stored. 70 | variation_id: ID for variation that the user saw. 71 | """ 72 | self.experiment_bucket_map.update({experiment_id: {self.VARIATION_ID_KEY: variation_id}}) 73 | 74 | 75 | class UserProfileService: 76 | """ Class encapsulating user profile service functionality. 77 | Override with your own implementation for storing and retrieving the user profile. """ 78 | 79 | def lookup(self, user_id: str) -> dict[str, Any]: 80 | """ Fetch the user profile dict corresponding to the user ID. 81 | 82 | Args: 83 | user_id: ID for user whose profile needs to be retrieved. 84 | 85 | Returns: 86 | Dict representing the user's profile. 87 | """ 88 | return UserProfile(user_id).__dict__ 89 | 90 | def save(self, user_profile: dict[str, Any]) -> None: 91 | """ Save the user profile dict sent to this method. 92 | 93 | Args: 94 | user_profile: Dict representing the user's profile. 95 | """ 96 | pass 97 | 98 | 99 | class UserProfileTracker: 100 | def __init__(self, 101 | user_id: str, 102 | user_profile_service: Optional[UserProfileService], 103 | logger: Optional[_logging.Logger] = None): 104 | self.user_id = user_id 105 | self.user_profile_service = user_profile_service 106 | self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger()) 107 | self.profile_updated = False 108 | self.user_profile = UserProfile(user_id, {}) 109 | 110 | def get_user_profile(self) -> UserProfile: 111 | return self.user_profile 112 | 113 | def load_user_profile(self, reasons: Optional[list[str]] = [], 114 | error_handler: Optional[BaseErrorHandler] = None) -> None: 115 | if reasons is None: 116 | reasons = [] 117 | try: 118 | user_profile = self.user_profile_service.lookup(self.user_id) if self.user_profile_service else None 119 | if user_profile is None: 120 | message = "Unable to get a user profile from the UserProfileService." 121 | reasons.append(message) 122 | else: 123 | if 'user_id' in user_profile and 'experiment_bucket_map' in user_profile: 124 | self.user_profile = UserProfile( 125 | user_profile['user_id'], 126 | user_profile['experiment_bucket_map'] 127 | ) 128 | self.logger.info("User profile loaded successfully.") 129 | else: 130 | missing_keys = [key for key in ['user_id', 'experiment_bucket_map'] if key not in user_profile] 131 | message = f"User profile is missing keys: {', '.join(missing_keys)}" 132 | reasons.append(message) 133 | except Exception as exception: 134 | message = str(exception) 135 | reasons.append(message) 136 | self.logger.exception(f'Unable to retrieve user profile for user "{self.user_id}" as lookup failed.') 137 | if error_handler: 138 | error_handler.handle_error(exception) 139 | 140 | def update_user_profile(self, experiment: Experiment, variation: Variation) -> None: 141 | variation_id = variation.id 142 | experiment_id = experiment.id 143 | self.user_profile.save_variation_for_experiment(experiment_id, variation_id) 144 | self.profile_updated = True 145 | 146 | def save_user_profile(self, error_handler: Optional[BaseErrorHandler] = None) -> None: 147 | if not self.profile_updated: 148 | return 149 | try: 150 | if self.user_profile_service: 151 | self.user_profile_service.save(self.user_profile.__dict__) 152 | self.logger.info(f'Saved user profile of user "{self.user_profile.user_id}".') 153 | except Exception as exception: 154 | self.logger.warning(f'Failed to save user profile of user "{self.user_profile.user_id}" ' 155 | f'for exception:{exception}".') 156 | if error_handler: 157 | error_handler.handle_error(exception) 158 | -------------------------------------------------------------------------------- /optimizely/odp/odp_segment_api_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | 16 | import json 17 | from typing import Optional 18 | 19 | import requests 20 | from requests.exceptions import RequestException, ConnectionError, Timeout, JSONDecodeError 21 | 22 | from optimizely import logger as optimizely_logger 23 | from optimizely.helpers.enums import Errors, OdpSegmentApiConfig 24 | 25 | """ 26 | ODP GraphQL API 27 | - https://api.zaius.com/v3/graphql 28 | - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" 29 | 30 | 31 | [GraphQL Request] 32 | 33 | # fetch info with fs_user_id for ["has_email", "has_email_opted_in", "push_on_sale"] segments 34 | curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d 35 | '{"query":"query {customer(fs_user_id: \"tester-101\") {audiences(subset:[\"has_email\", 36 | \"has_email_opted_in\", \"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql 37 | # fetch info with vuid for ["has_email", "has_email_opted_in", "push_on_sale"] segments 38 | curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d 39 | '{"query":"query {customer(vuid: \"d66a9d81923d4d2f99d8f64338976322\") {audiences(subset:[\"has_email\", 40 | \"has_email_opted_in\", \"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql 41 | 42 | query MyQuery { 43 | customer(vuid: "d66a9d81923d4d2f99d8f64338976322") { 44 | audiences(subset:["has_email", "has_email_opted_in", "push_on_sale"]) { 45 | edges { 46 | node { 47 | name 48 | state 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | 56 | [GraphQL Response] 57 | { 58 | "data": { 59 | "customer": { 60 | "audiences": { 61 | "edges": [ 62 | { 63 | "node": { 64 | "name": "has_email", 65 | "state": "qualified", 66 | } 67 | }, 68 | { 69 | "node": { 70 | "name": "has_email_opted_in", 71 | "state": "qualified", 72 | } 73 | }, 74 | ... 75 | ] 76 | } 77 | } 78 | } 79 | } 80 | 81 | [GraphQL Error Response] 82 | { 83 | "errors": [ 84 | { 85 | "message": "Exception while fetching data (/customer) : java.lang.RuntimeException: 86 | could not resolve _fs_user_id = asdsdaddddd", 87 | "locations": [ 88 | { 89 | "line": 2, 90 | "column": 3 91 | } 92 | ], 93 | "path": [ 94 | "customer" 95 | ], 96 | "extensions": { 97 | "classification": "InvalidIdentifierException" 98 | } 99 | } 100 | ], 101 | "data": { 102 | "customer": null 103 | } 104 | } 105 | """ 106 | 107 | 108 | class OdpSegmentApiManager: 109 | """Interface for manging the fetching of audience segments.""" 110 | 111 | def __init__(self, logger: Optional[optimizely_logger.Logger] = None, timeout: Optional[int] = None): 112 | self.logger = logger or optimizely_logger.NoOpLogger() 113 | self.timeout = timeout or OdpSegmentApiConfig.REQUEST_TIMEOUT 114 | 115 | def fetch_segments(self, api_key: str, api_host: str, user_key: str, 116 | user_value: str, segments_to_check: list[str]) -> Optional[list[str]]: 117 | """ 118 | Fetch segments from ODP GraphQL API. 119 | 120 | Args: 121 | api_key: public api key 122 | api_host: domain url of the host 123 | user_key: vuid or fs_user_id (client device id or fullstack id) 124 | user_value: vaue of user_key 125 | segments_to_check: lit of segments to check 126 | 127 | Returns: 128 | Audience segments from GraphQL. 129 | """ 130 | url = f'{api_host}/v3/graphql' 131 | request_headers = {'content-type': 'application/json', 132 | 'x-api-key': str(api_key)} 133 | 134 | query = { 135 | 'query': 136 | 'query($userId: String, $audiences: [String]) {' 137 | f'customer({user_key}: $userId) ' 138 | '{audiences(subset: $audiences) {edges {node {name state}}}}}', 139 | 'variables': { 140 | 'userId': str(user_value), 141 | 'audiences': segments_to_check} 142 | } 143 | 144 | try: 145 | payload_dict = json.dumps(query) 146 | except TypeError as err: 147 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format(err)) 148 | return None 149 | 150 | try: 151 | response = requests.post(url=url, 152 | headers=request_headers, 153 | data=payload_dict, 154 | timeout=self.timeout) 155 | 156 | response.raise_for_status() 157 | response_dict = response.json() 158 | 159 | # There is no status code with network issues such as ConnectionError or Timeouts 160 | # (i.e. no internet, server can't be reached). 161 | except (ConnectionError, Timeout) as err: 162 | self.logger.debug(f'GraphQL download failed: {err}') 163 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('network error')) 164 | return None 165 | except JSONDecodeError: 166 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('JSON decode error')) 167 | return None 168 | except RequestException as err: 169 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format(err)) 170 | return None 171 | 172 | if response_dict and 'errors' in response_dict: 173 | try: 174 | extensions = response_dict['errors'][0]['extensions'] 175 | error_class = extensions['classification'] 176 | error_code = extensions.get('code') 177 | except (KeyError, IndexError, TypeError): 178 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('decode error')) 179 | return None 180 | 181 | if error_code == 'INVALID_IDENTIFIER_EXCEPTION': 182 | self.logger.warning(Errors.FETCH_SEGMENTS_FAILED.format('invalid identifier')) 183 | return None 184 | else: 185 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format(error_class)) 186 | return None 187 | else: 188 | try: 189 | audiences = response_dict['data']['customer']['audiences']['edges'] 190 | segments = [edge['node']['name'] for edge in audiences if edge['node']['state'] == 'qualified'] 191 | return segments 192 | except (KeyError, TypeError): 193 | self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('decode error')) 194 | return None 195 | -------------------------------------------------------------------------------- /optimizely/helpers/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2017, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | JSON_SCHEMA = { 15 | "$schema": "http://json-schema.org/draft-04/schema#", 16 | "type": "object", 17 | "properties": { 18 | "projectId": {"type": "string"}, 19 | "accountId": {"type": "string"}, 20 | "groups": { 21 | "type": "array", 22 | "items": { 23 | "type": "object", 24 | "properties": { 25 | "id": {"type": "string"}, 26 | "policy": {"type": "string"}, 27 | "trafficAllocation": { 28 | "type": "array", 29 | "items": { 30 | "type": "object", 31 | "properties": {"entityId": {"type": "string"}, "endOfRange": {"type": "integer"}}, 32 | "required": ["entityId", "endOfRange"], 33 | }, 34 | }, 35 | "experiments": { 36 | "type": "array", 37 | "items": { 38 | "type": "object", 39 | "properties": { 40 | "id": {"type": "string"}, 41 | "layerId": {"type": "string"}, 42 | "key": {"type": "string"}, 43 | "status": {"type": "string"}, 44 | "variations": { 45 | "type": "array", 46 | "items": { 47 | "type": "object", 48 | "properties": {"id": {"type": "string"}, "key": {"type": "string"}}, 49 | "required": ["id", "key"], 50 | }, 51 | }, 52 | "trafficAllocation": { 53 | "type": "array", 54 | "items": { 55 | "type": "object", 56 | "properties": { 57 | "entityId": {"type": "string"}, 58 | "endOfRange": {"type": "integer"}, 59 | }, 60 | "required": ["entityId", "endOfRange"], 61 | }, 62 | }, 63 | "audienceIds": {"type": "array", "items": {"type": "string"}}, 64 | "forcedVariations": {"type": "object"}, 65 | }, 66 | "required": [ 67 | "id", 68 | "layerId", 69 | "key", 70 | "status", 71 | "variations", 72 | "trafficAllocation", 73 | "audienceIds", 74 | "forcedVariations", 75 | ], 76 | }, 77 | }, 78 | }, 79 | "required": ["id", "policy", "trafficAllocation", "experiments"], 80 | }, 81 | }, 82 | "experiments": { 83 | "type": "array", 84 | "items": { 85 | "type": "object", 86 | "properties": { 87 | "id": {"type": "string"}, 88 | "layerId": {"type": "string"}, 89 | "key": {"type": "string"}, 90 | "status": {"type": "string"}, 91 | "variations": { 92 | "type": "array", 93 | "items": { 94 | "type": "object", 95 | "properties": {"id": {"type": "string"}, "key": {"type": "string"}}, 96 | "required": ["id", "key"], 97 | }, 98 | }, 99 | "trafficAllocation": { 100 | "type": "array", 101 | "items": { 102 | "type": "object", 103 | "properties": {"entityId": {"type": "string"}, "endOfRange": {"type": "integer"}}, 104 | "required": ["entityId", "endOfRange"], 105 | }, 106 | }, 107 | "audienceIds": {"type": "array", "items": {"type": "string"}}, 108 | "forcedVariations": {"type": "object"}, 109 | }, 110 | "required": [ 111 | "id", 112 | "layerId", 113 | "key", 114 | "status", 115 | "variations", 116 | "trafficAllocation", 117 | "audienceIds", 118 | "forcedVariations", 119 | ], 120 | }, 121 | }, 122 | "events": { 123 | "type": "array", 124 | "items": { 125 | "type": "object", 126 | "properties": { 127 | "key": {"type": "string"}, 128 | "experimentIds": {"type": "array", "items": {"type": "string"}}, 129 | "id": {"type": "string"}, 130 | }, 131 | "required": ["key", "experimentIds", "id"], 132 | }, 133 | }, 134 | "audiences": { 135 | "type": "array", 136 | "items": { 137 | "type": "object", 138 | "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "conditions": {"type": "string"}}, 139 | "required": ["id", "name", "conditions"], 140 | }, 141 | }, 142 | "attributes": { 143 | "type": "array", 144 | "items": { 145 | "type": "object", 146 | "properties": {"id": {"type": "string"}, "key": {"type": "string"}}, 147 | "required": ["id", "key"], 148 | }, 149 | }, 150 | "version": {"type": "string"}, 151 | "revision": {"type": "string"}, 152 | "integrations": { 153 | "type": "array", 154 | "items": { 155 | "type": "object", 156 | "properties": {"key": {"type": "string"}, "host": {"type": "string"}, "publicKey": {"type": "string"}}, 157 | "required": ["key"], 158 | } 159 | } 160 | }, 161 | "required": [ 162 | "projectId", 163 | "accountId", 164 | "groups", 165 | "experiments", 166 | "events", 167 | "audiences", 168 | "attributes", 169 | "version", 170 | "revision", 171 | ], 172 | } 173 | -------------------------------------------------------------------------------- /tests/test_lru_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # https://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | import time 16 | from unittest import TestCase 17 | from optimizely.odp.lru_cache import LRUCache, OptimizelySegmentsCache 18 | 19 | 20 | class LRUCacheTest(TestCase): 21 | def test_min_config(self): 22 | cache = LRUCache(1000, 2000) 23 | self.assertEqual(1000, cache.capacity) 24 | self.assertEqual(2000, cache.timeout) 25 | 26 | cache = LRUCache(0, 0) 27 | self.assertEqual(0, cache.capacity) 28 | self.assertEqual(0, cache.timeout) 29 | 30 | def test_save_and_lookup(self): 31 | max_size = 2 32 | cache = LRUCache(max_size, 1000) 33 | 34 | self.assertIsNone(cache.peek(1)) 35 | cache.save(1, 100) # [1] 36 | cache.save(2, 200) # [1, 2] 37 | cache.save(3, 300) # [2, 3] 38 | self.assertIsNone(cache.peek(1)) 39 | self.assertEqual(200, cache.peek(2)) 40 | self.assertEqual(300, cache.peek(3)) 41 | 42 | cache.save(2, 201) # [3, 2] 43 | cache.save(1, 101) # [2, 1] 44 | self.assertEqual(101, cache.peek(1)) 45 | self.assertEqual(201, cache.peek(2)) 46 | self.assertIsNone(cache.peek(3)) 47 | 48 | self.assertIsNone(cache.lookup(3)) # [2, 1] 49 | self.assertEqual(201, cache.lookup(2)) # [1, 2] 50 | cache.save(3, 302) # [2, 3] 51 | self.assertIsNone(cache.peek(1)) 52 | self.assertEqual(201, cache.peek(2)) 53 | self.assertEqual(302, cache.peek(3)) 54 | 55 | self.assertEqual(302, cache.lookup(3)) # [2, 3] 56 | cache.save(1, 103) # [3, 1] 57 | self.assertEqual(103, cache.peek(1)) 58 | self.assertIsNone(cache.peek(2)) 59 | self.assertEqual(302, cache.peek(3)) 60 | 61 | self.assertEqual(len(cache.map), max_size) 62 | self.assertEqual(len(cache.map), cache.capacity) 63 | 64 | def test_size_zero(self): 65 | cache = LRUCache(0, 1000) 66 | 67 | self.assertIsNone(cache.lookup(1)) 68 | cache.save(1, 100) # [1] 69 | self.assertIsNone(cache.lookup(1)) 70 | 71 | def test_size_less_than_zero(self): 72 | cache = LRUCache(-2, 1000) 73 | 74 | self.assertIsNone(cache.lookup(1)) 75 | cache.save(1, 100) # [1] 76 | self.assertIsNone(cache.lookup(1)) 77 | 78 | def test_timeout(self): 79 | max_timeout = .5 80 | 81 | cache = LRUCache(1000, max_timeout) 82 | 83 | cache.save(1, 100) # [1] 84 | cache.save(2, 200) # [1, 2] 85 | cache.save(3, 300) # [1, 2, 3] 86 | time.sleep(1.1) # wait to expire 87 | cache.save(4, 400) # [1, 2, 3, 4] 88 | cache.save(1, 101) # [2, 3, 4, 1] 89 | 90 | self.assertEqual(101, cache.lookup(1)) # [4, 1] 91 | self.assertIsNone(cache.lookup(2)) 92 | self.assertIsNone(cache.lookup(3)) 93 | self.assertEqual(400, cache.lookup(4)) 94 | 95 | def test_timeout_zero(self): 96 | max_timeout = 0 97 | cache = LRUCache(1000, max_timeout) 98 | 99 | cache.save(1, 100) # [1] 100 | cache.save(2, 200) # [1, 2] 101 | time.sleep(1) # wait to expire 102 | 103 | self.assertEqual(100, cache.lookup(1), "should not expire when timeout is 0") 104 | self.assertEqual(200, cache.lookup(2)) 105 | 106 | def test_timeout_less_than_zero(self): 107 | max_timeout = -2 108 | cache = LRUCache(1000, max_timeout) 109 | 110 | cache.save(1, 100) # [1] 111 | cache.save(2, 200) # [1, 2] 112 | time.sleep(1) # wait to expire 113 | 114 | self.assertEqual(100, cache.lookup(1), "should not expire when timeout is less than 0") 115 | self.assertEqual(200, cache.lookup(2)) 116 | 117 | def test_reset(self): 118 | cache = LRUCache(1000, 600) 119 | cache.save('wow', 'great') 120 | cache.save('tow', 'freight') 121 | 122 | self.assertEqual(cache.lookup('wow'), 'great') 123 | self.assertEqual(len(cache.map), 2) 124 | 125 | cache.reset() 126 | 127 | self.assertEqual(cache.lookup('wow'), None) 128 | self.assertEqual(len(cache.map), 0) 129 | 130 | cache.save('cow', 'crate') 131 | self.assertEqual(cache.lookup('cow'), 'crate') 132 | 133 | def test_remove_non_existent_key(self): 134 | cache = LRUCache(3, 1000) 135 | cache.save("1", 100) 136 | cache.save("2", 200) 137 | 138 | cache.remove("3") # Doesn't exist 139 | 140 | self.assertEqual(cache.lookup("1"), 100) 141 | self.assertEqual(cache.lookup("2"), 200) 142 | 143 | def test_remove_existing_key(self): 144 | cache = LRUCache(3, 1000) 145 | 146 | cache.save("1", 100) 147 | cache.save("2", 200) 148 | cache.save("3", 300) 149 | 150 | self.assertEqual(cache.lookup("1"), 100) 151 | self.assertEqual(cache.lookup("2"), 200) 152 | self.assertEqual(cache.lookup("3"), 300) 153 | 154 | cache.remove("2") 155 | 156 | self.assertEqual(cache.lookup("1"), 100) 157 | self.assertIsNone(cache.lookup("2")) 158 | self.assertEqual(cache.lookup("3"), 300) 159 | 160 | def test_remove_from_zero_sized_cache(self): 161 | cache = LRUCache(0, 1000) 162 | cache.save("1", 100) 163 | cache.remove("1") 164 | 165 | self.assertIsNone(cache.lookup("1")) 166 | 167 | def test_remove_and_add_back(self): 168 | cache = LRUCache(3, 1000) 169 | cache.save("1", 100) 170 | cache.save("2", 200) 171 | cache.save("3", 300) 172 | 173 | cache.remove("2") 174 | cache.save("2", 201) 175 | 176 | self.assertEqual(cache.lookup("1"), 100) 177 | self.assertEqual(cache.lookup("2"), 201) 178 | self.assertEqual(cache.lookup("3"), 300) 179 | 180 | def test_thread_safety(self): 181 | import threading 182 | 183 | max_size = 100 184 | cache = LRUCache(max_size, 1000) 185 | 186 | for i in range(1, max_size + 1): 187 | cache.save(str(i), i * 100) 188 | 189 | def remove_key(k): 190 | cache.remove(str(k)) 191 | 192 | threads = [] 193 | for i in range(1, (max_size // 2) + 1): 194 | thread = threading.Thread(target=remove_key, args=(i,)) 195 | threads.append(thread) 196 | thread.start() 197 | 198 | for thread in threads: 199 | thread.join() 200 | 201 | for i in range(1, max_size + 1): 202 | if i <= max_size // 2: 203 | self.assertIsNone(cache.lookup(str(i))) 204 | else: 205 | self.assertEqual(cache.lookup(str(i)), i * 100) 206 | 207 | self.assertEqual(len(cache.map), max_size // 2) 208 | 209 | # type checker test 210 | # confirm that LRUCache matches OptimizelySegmentsCache protocol 211 | _: OptimizelySegmentsCache = LRUCache(0, 0) 212 | -------------------------------------------------------------------------------- /tests/helpers_tests/test_event_tag_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import sys 15 | import unittest 16 | 17 | 18 | from optimizely.helpers import event_tag_utils 19 | from optimizely.logger import NoOpLogger 20 | 21 | 22 | class EventTagUtilsTest(unittest.TestCase): 23 | def setUp(self, *args, **kwargs): 24 | self.logger = NoOpLogger() 25 | 26 | def test_get_revenue_value__invalid_args(self): 27 | """ Test that revenue value is not returned for invalid arguments. """ 28 | self.assertIsNone(event_tag_utils.get_revenue_value(None)) 29 | self.assertIsNone(event_tag_utils.get_revenue_value(0.5)) 30 | self.assertIsNone(event_tag_utils.get_revenue_value(65536)) 31 | self.assertIsNone(event_tag_utils.get_revenue_value(9223372036854775807)) 32 | self.assertIsNone(event_tag_utils.get_revenue_value('9223372036854775807')) 33 | self.assertIsNone(event_tag_utils.get_revenue_value(True)) 34 | self.assertIsNone(event_tag_utils.get_revenue_value(False)) 35 | 36 | def test_get_revenue_value__no_revenue_tag(self): 37 | """ Test that revenue value is not returned when there's no revenue event tag. """ 38 | self.assertIsNone(event_tag_utils.get_revenue_value([])) 39 | self.assertIsNone(event_tag_utils.get_revenue_value({})) 40 | self.assertIsNone(event_tag_utils.get_revenue_value({'non-revenue': 42})) 41 | 42 | def test_get_revenue_value__invalid_revenue_tag(self): 43 | """ Test that revenue value is not returned when revenue event tag has invalid data type. """ 44 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': None})) 45 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': 0.5})) 46 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': '65536'})) 47 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': True})) 48 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': False})) 49 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': [1, 2, 3]})) 50 | self.assertIsNone(event_tag_utils.get_revenue_value({'revenue': {'a', 'b', 'c'}})) 51 | 52 | def test_get_revenue_value__revenue_tag(self): 53 | """ Test that correct revenue value is returned. """ 54 | self.assertEqual(0, event_tag_utils.get_revenue_value({'revenue': 0})) 55 | self.assertEqual(65536, event_tag_utils.get_revenue_value({'revenue': 65536})) 56 | self.assertEqual( 57 | 9223372036854775807, event_tag_utils.get_revenue_value({'revenue': 9223372036854775807}), 58 | ) 59 | 60 | def test_get_numeric_metric__invalid_args(self): 61 | """ Test that numeric value is not returned for invalid arguments. """ 62 | self.assertIsNone(event_tag_utils.get_numeric_value(None)) 63 | self.assertIsNone(event_tag_utils.get_numeric_value(0.5)) 64 | self.assertIsNone(event_tag_utils.get_numeric_value(65536)) 65 | self.assertIsNone(event_tag_utils.get_numeric_value(9223372036854775807)) 66 | self.assertIsNone(event_tag_utils.get_numeric_value('9223372036854775807')) 67 | self.assertIsNone(event_tag_utils.get_numeric_value(True)) 68 | self.assertIsNone(event_tag_utils.get_numeric_value(False)) 69 | 70 | def test_get_numeric_metric__no_value_tag(self): 71 | """ Test that numeric value is not returned when there's no numeric event tag. """ 72 | self.assertIsNone(event_tag_utils.get_numeric_value([])) 73 | self.assertIsNone(event_tag_utils.get_numeric_value({})) 74 | self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': 42})) 75 | 76 | def test_get_numeric_metric__invalid_value_tag(self): 77 | """ Test that numeric value is not returned when value event tag has invalid data type. """ 78 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': None})) 79 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': True})) 80 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': False})) 81 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': [1, 2, 3]})) 82 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': {'a', 'b', 'c'}})) 83 | 84 | def test_get_numeric_metric__value_tag(self): 85 | """ Test that the correct numeric value is returned. """ 86 | 87 | # An integer should be cast to a float 88 | self.assertEqual( 89 | 12345.0, event_tag_utils.get_numeric_value({'value': 12345}), 90 | ) 91 | 92 | # A string should be cast to a float 93 | self.assertEqual( 94 | 12345.0, event_tag_utils.get_numeric_value({'value': '12345'}, self.logger), 95 | ) 96 | 97 | # Valid float values 98 | some_float = 1.2345 99 | self.assertEqual( 100 | some_float, event_tag_utils.get_numeric_value({'value': some_float}, self.logger), 101 | ) 102 | 103 | max_float = sys.float_info.max 104 | self.assertEqual( 105 | max_float, event_tag_utils.get_numeric_value({'value': max_float}, self.logger), 106 | ) 107 | 108 | min_float = sys.float_info.min 109 | self.assertEqual( 110 | min_float, event_tag_utils.get_numeric_value({'value': min_float}, self.logger), 111 | ) 112 | 113 | # Invalid values 114 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': False}, self.logger)) 115 | self.assertIsNone(event_tag_utils.get_numeric_value({'value': None}, self.logger)) 116 | 117 | numeric_value_nan = event_tag_utils.get_numeric_value({'value': float('nan')}, self.logger) 118 | self.assertIsNone(numeric_value_nan, f'nan numeric value is {numeric_value_nan}') 119 | 120 | numeric_value_array = event_tag_utils.get_numeric_value({'value': []}, self.logger) 121 | self.assertIsNone(numeric_value_array, f'Array numeric value is {numeric_value_array}') 122 | 123 | numeric_value_dict = event_tag_utils.get_numeric_value({'value': []}, self.logger) 124 | self.assertIsNone(numeric_value_dict, f'Dict numeric value is {numeric_value_dict}') 125 | 126 | numeric_value_none = event_tag_utils.get_numeric_value({'value': None}, self.logger) 127 | self.assertIsNone(numeric_value_none, f'None numeric value is {numeric_value_none}') 128 | 129 | numeric_value_invalid_literal = event_tag_utils.get_numeric_value( 130 | {'value': '1,234'}, self.logger 131 | ) 132 | self.assertIsNone( 133 | numeric_value_invalid_literal, f'Invalid string literal value is {numeric_value_invalid_literal}', 134 | ) 135 | 136 | numeric_value_overflow = event_tag_utils.get_numeric_value( 137 | {'value': sys.float_info.max * 10}, self.logger 138 | ) 139 | self.assertIsNone( 140 | numeric_value_overflow, f'Max numeric value is {numeric_value_overflow}', 141 | ) 142 | 143 | numeric_value_inf = event_tag_utils.get_numeric_value({'value': float('inf')}, self.logger) 144 | self.assertIsNone(numeric_value_inf, f'Infinity numeric value is {numeric_value_inf}') 145 | 146 | numeric_value_neg_inf = event_tag_utils.get_numeric_value( 147 | {'value': float('-inf')}, self.logger 148 | ) 149 | self.assertIsNone( 150 | numeric_value_neg_inf, f'Negative infinity numeric value is {numeric_value_neg_inf}', 151 | ) 152 | 153 | self.assertEqual( 154 | 0.0, event_tag_utils.get_numeric_value({'value': 0.0}, self.logger), 155 | ) 156 | -------------------------------------------------------------------------------- /optimizely/cmab/cmab_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | import uuid 14 | import json 15 | import hashlib 16 | import threading 17 | 18 | from typing import Optional, List, TypedDict, Tuple 19 | from optimizely.cmab.cmab_client import DefaultCmabClient 20 | from optimizely.odp.lru_cache import LRUCache 21 | from optimizely.optimizely_user_context import OptimizelyUserContext, UserAttributes 22 | from optimizely.project_config import ProjectConfig 23 | from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption 24 | from optimizely import logger as _logging 25 | from optimizely.lib import pymmh3 as mmh3 26 | 27 | NUM_LOCK_STRIPES = 1000 28 | DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 # 30 minutes 29 | DEFAULT_CMAB_CACHE_SIZE = 10000 30 | 31 | 32 | class CmabDecision(TypedDict): 33 | variation_id: str 34 | cmab_uuid: str 35 | 36 | 37 | class CmabCacheValue(TypedDict): 38 | attributes_hash: str 39 | variation_id: str 40 | cmab_uuid: str 41 | 42 | 43 | class DefaultCmabService: 44 | """ 45 | DefaultCmabService handles decisioning for Contextual Multi-Armed Bandit (CMAB) experiments, 46 | including caching and filtering user attributes for efficient decision retrieval. 47 | 48 | Attributes: 49 | cmab_cache: LRUCache for user CMAB decisions. 50 | cmab_client: Client to fetch decisions from the CMAB backend. 51 | logger: Optional logger. 52 | 53 | Methods: 54 | get_decision: Retrieves a CMAB decision with caching and attribute filtering. 55 | """ 56 | def __init__(self, cmab_cache: LRUCache[str, CmabCacheValue], 57 | cmab_client: DefaultCmabClient, logger: Optional[_logging.Logger] = None): 58 | self.cmab_cache = cmab_cache 59 | self.cmab_client = cmab_client 60 | self.logger = logger 61 | self.locks = [threading.Lock() for _ in range(NUM_LOCK_STRIPES)] 62 | 63 | def _get_lock_index(self, user_id: str, rule_id: str) -> int: 64 | """Calculate the lock index for a given user and rule combination.""" 65 | # Create a hash of user_id + rule_id for consistent lock selection 66 | hash_input = f"{user_id}{rule_id}" 67 | hash_value = mmh3.hash(hash_input, seed=0) & 0xFFFFFFFF # Convert to unsigned 68 | return hash_value % NUM_LOCK_STRIPES 69 | 70 | def get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUserContext, 71 | rule_id: str, options: List[str]) -> Tuple[CmabDecision, List[str]]: 72 | 73 | lock_index = self._get_lock_index(user_context.user_id, rule_id) 74 | with self.locks[lock_index]: 75 | return self._get_decision(project_config, user_context, rule_id, options) 76 | 77 | def _get_decision(self, project_config: ProjectConfig, user_context: OptimizelyUserContext, 78 | rule_id: str, options: List[str]) -> Tuple[CmabDecision, List[str]]: 79 | 80 | filtered_attributes = self._filter_attributes(project_config, user_context, rule_id) 81 | reasons = [] 82 | 83 | if OptimizelyDecideOption.IGNORE_CMAB_CACHE in options: 84 | reason = f"Ignoring CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'" 85 | if self.logger: 86 | self.logger.debug(reason) 87 | reasons.append(reason) 88 | cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes) 89 | return cmab_decision, reasons 90 | 91 | if OptimizelyDecideOption.RESET_CMAB_CACHE in options: 92 | reason = f"Resetting CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'" 93 | if self.logger: 94 | self.logger.debug(reason) 95 | reasons.append(reason) 96 | self.cmab_cache.reset() 97 | 98 | cache_key = self._get_cache_key(user_context.user_id, rule_id) 99 | 100 | if OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE in options: 101 | reason = f"Invalidating CMAB cache for user '{user_context.user_id}' and rule '{rule_id}'" 102 | if self.logger: 103 | self.logger.debug(reason) 104 | reasons.append(reason) 105 | self.cmab_cache.remove(cache_key) 106 | 107 | cached_value = self.cmab_cache.lookup(cache_key) 108 | 109 | attributes_hash = self._hash_attributes(filtered_attributes) 110 | 111 | if cached_value: 112 | if cached_value['attributes_hash'] == attributes_hash: 113 | reason = f"CMAB cache hit for user '{user_context.user_id}' and rule '{rule_id}'" 114 | if self.logger: 115 | self.logger.debug(reason) 116 | reasons.append(reason) 117 | return CmabDecision(variation_id=cached_value['variation_id'], 118 | cmab_uuid=cached_value['cmab_uuid']), reasons 119 | else: 120 | reason = ( 121 | f"CMAB cache attributes mismatch for user '{user_context.user_id}' " 122 | f"and rule '{rule_id}', fetching new decision." 123 | ) 124 | if self.logger: 125 | self.logger.debug(reason) 126 | reasons.append(reason) 127 | self.cmab_cache.remove(cache_key) 128 | else: 129 | reason = f"CMAB cache miss for user '{user_context.user_id}' and rule '{rule_id}'" 130 | if self.logger: 131 | self.logger.debug(reason) 132 | reasons.append(reason) 133 | 134 | cmab_decision = self._fetch_decision(rule_id, user_context.user_id, filtered_attributes) 135 | reason = f"CMAB decision is {cmab_decision}" 136 | if self.logger: 137 | self.logger.debug(reason) 138 | reasons.append(reason) 139 | 140 | self.cmab_cache.save(cache_key, { 141 | 'attributes_hash': attributes_hash, 142 | 'variation_id': cmab_decision['variation_id'], 143 | 'cmab_uuid': cmab_decision['cmab_uuid'], 144 | }) 145 | return cmab_decision, reasons 146 | 147 | def _fetch_decision(self, rule_id: str, user_id: str, attributes: UserAttributes) -> CmabDecision: 148 | cmab_uuid = str(uuid.uuid4()) 149 | variation_id = self.cmab_client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) 150 | cmab_decision = CmabDecision(variation_id=variation_id, cmab_uuid=cmab_uuid) 151 | return cmab_decision 152 | 153 | def _filter_attributes(self, project_config: ProjectConfig, 154 | user_context: OptimizelyUserContext, rule_id: str) -> UserAttributes: 155 | user_attributes = user_context.get_user_attributes() 156 | filtered_user_attributes = UserAttributes({}) 157 | 158 | experiment = project_config.experiment_id_map.get(rule_id) 159 | if not experiment or not experiment.cmab: 160 | return filtered_user_attributes 161 | 162 | cmab_attribute_ids = experiment.cmab['attributeIds'] 163 | for attribute_id in cmab_attribute_ids: 164 | attribute = project_config.attribute_id_map.get(attribute_id) 165 | if attribute and attribute.key in user_attributes: 166 | filtered_user_attributes[attribute.key] = user_attributes[attribute.key] 167 | 168 | return filtered_user_attributes 169 | 170 | def _get_cache_key(self, user_id: str, rule_id: str) -> str: 171 | return f"{len(user_id)}-{user_id}-{rule_id}" 172 | 173 | def _hash_attributes(self, attributes: UserAttributes) -> str: 174 | sorted_attrs = json.dumps(attributes, sort_keys=True) 175 | return hashlib.md5(sorted_attrs.encode()).hexdigest() 176 | -------------------------------------------------------------------------------- /tests/test_odp_event_api_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # https://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import json 15 | from unittest import mock 16 | 17 | from requests import exceptions as request_exception 18 | 19 | from optimizely.helpers.enums import OdpEventApiConfig 20 | from optimizely.odp.odp_event import OdpEvent, OdpEventEncoder 21 | from optimizely.odp.odp_event_api_manager import OdpEventApiManager 22 | from . import base 23 | 24 | 25 | class OdpEventApiManagerTest(base.BaseTest): 26 | user_key = "vuid" 27 | user_value = "test-user-value" 28 | api_key = "test-api-key" 29 | api_host = "test-host" 30 | events = [ 31 | OdpEvent('t1', 'a1', {"id-key-1": "id-value-1"}, {"key-1": "value1"}), 32 | OdpEvent('t2', 'a2', {"id-key-2": "id-value-2"}, {"key-2": "value2"}) 33 | ] 34 | 35 | def test_send_odp_events__valid_request(self): 36 | with mock.patch('requests.post') as mock_request_post: 37 | api = OdpEventApiManager() 38 | api.send_odp_events(api_key=self.api_key, 39 | api_host=self.api_host, 40 | events=self.events) 41 | 42 | request_headers = {'content-type': 'application/json', 'x-api-key': self.api_key} 43 | mock_request_post.assert_called_once_with(url=self.api_host + "/v3/events", 44 | headers=request_headers, 45 | data=json.dumps(self.events, cls=OdpEventEncoder), 46 | timeout=OdpEventApiConfig.REQUEST_TIMEOUT) 47 | 48 | def test_send_odp_events__custom_timeout(self): 49 | with mock.patch('requests.post') as mock_request_post: 50 | api = OdpEventApiManager(timeout=14) 51 | api.send_odp_events(api_key=self.api_key, 52 | api_host=self.api_host, 53 | events=self.events) 54 | 55 | request_headers = {'content-type': 'application/json', 'x-api-key': self.api_key} 56 | mock_request_post.assert_called_once_with(url=self.api_host + "/v3/events", 57 | headers=request_headers, 58 | data=json.dumps(self.events, cls=OdpEventEncoder), 59 | timeout=14) 60 | 61 | def test_send_odp_ovents_success(self): 62 | with mock.patch('requests.post') as mock_request_post: 63 | # no need to mock url and content because we're not returning the response 64 | mock_request_post.return_value = self.fake_server_response(status_code=200) 65 | 66 | api = OdpEventApiManager() 67 | should_retry = api.send_odp_events(api_key=self.api_key, 68 | api_host=self.api_host, 69 | events=self.events) # content of events doesn't matter for the test 70 | 71 | self.assertFalse(should_retry) 72 | 73 | def test_send_odp_events_invalid_json_no_retry(self): 74 | """Using a set to trigger JSON-not-serializable error.""" 75 | events = {1, 2, 3} 76 | 77 | with mock.patch('requests.post') as mock_request_post, \ 78 | mock.patch('optimizely.logger') as mock_logger: 79 | api = OdpEventApiManager(logger=mock_logger) 80 | should_retry = api.send_odp_events(api_key=self.api_key, 81 | api_host=self.api_host, 82 | events=events) 83 | 84 | self.assertFalse(should_retry) 85 | mock_request_post.assert_not_called() 86 | mock_logger.error.assert_called_once_with( 87 | 'ODP event send failed (Object of type set is not JSON serializable).') 88 | 89 | def test_send_odp_events_invalid_url_no_retry(self): 90 | invalid_url = 'https://*api.zaius.com' 91 | 92 | with mock.patch('requests.post', 93 | side_effect=request_exception.InvalidURL('Invalid URL')) as mock_request_post, \ 94 | mock.patch('optimizely.logger') as mock_logger: 95 | api = OdpEventApiManager(logger=mock_logger) 96 | should_retry = api.send_odp_events(api_key=self.api_key, 97 | api_host=invalid_url, 98 | events=self.events) 99 | 100 | self.assertFalse(should_retry) 101 | mock_request_post.assert_called_once() 102 | mock_logger.error.assert_called_once_with('ODP event send failed (Invalid URL).') 103 | 104 | def test_send_odp_events_network_error_retry(self): 105 | with mock.patch('requests.post', 106 | side_effect=request_exception.ConnectionError('Connection error')) as mock_request_post, \ 107 | mock.patch('optimizely.logger') as mock_logger: 108 | api = OdpEventApiManager(logger=mock_logger) 109 | should_retry = api.send_odp_events(api_key=self.api_key, 110 | api_host=self.api_host, 111 | events=self.events) 112 | 113 | self.assertTrue(should_retry) 114 | mock_request_post.assert_called_once() 115 | mock_logger.error.assert_called_once_with('ODP event send failed (network error).') 116 | 117 | def test_send_odp_events_400_no_retry(self): 118 | with mock.patch('requests.post') as mock_request_post, \ 119 | mock.patch('optimizely.logger') as mock_logger: 120 | mock_request_post.return_value = self.fake_server_response(status_code=400, 121 | url=self.api_host, 122 | content=self.failure_response_data) 123 | 124 | api = OdpEventApiManager(logger=mock_logger) 125 | should_retry = api.send_odp_events(api_key=self.api_key, 126 | api_host=self.api_host, 127 | events=self.events) 128 | 129 | self.assertFalse(should_retry) 130 | mock_request_post.assert_called_once() 131 | mock_logger.error.assert_called_once_with('ODP event send failed ({"title":"Bad Request","status":400,' 132 | '"timestamp":"2022-07-01T20:44:00.945Z","detail":{"invalids":' 133 | '[{"event":0,"message":"missing \'type\' field"}]}}).') 134 | 135 | def test_send_odp_events_500_retry(self): 136 | with mock.patch('requests.post') as mock_request_post, \ 137 | mock.patch('optimizely.logger') as mock_logger: 138 | mock_request_post.return_value = self.fake_server_response(status_code=500, url=self.api_host) 139 | 140 | api = OdpEventApiManager(logger=mock_logger) 141 | should_retry = api.send_odp_events(api_key=self.api_key, 142 | api_host=self.api_host, 143 | events=self.events) 144 | 145 | self.assertTrue(should_retry) 146 | mock_request_post.assert_called_once() 147 | mock_logger.error.assert_called_once_with('ODP event send failed (500 Server Error: None for url: test-host).') 148 | 149 | # test json responses 150 | success_response_data = '{"title":"Accepted","status":202,"timestamp":"2022-07-01T16:04:06.786Z"}' 151 | 152 | failure_response_data = '{"title":"Bad Request","status":400,"timestamp":"2022-07-01T20:44:00.945Z",' \ 153 | '"detail":{"invalids":[{"event":0,"message":"missing \'type\' field"}]}}' 154 | -------------------------------------------------------------------------------- /optimizely/event/event_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019, 2022, Optimizely 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from __future__ import annotations 15 | from typing import TYPE_CHECKING, Optional, Sequence, cast, List 16 | from sys import version_info 17 | from optimizely import entities 18 | from optimizely.helpers import enums 19 | from optimizely.helpers import event_tag_utils 20 | from optimizely.helpers import validator 21 | from . import log_event 22 | from . import payload 23 | from . import user_event 24 | 25 | if version_info < (3, 8): 26 | from typing_extensions import Final 27 | else: 28 | from typing import Final 29 | 30 | if TYPE_CHECKING: 31 | # prevent circular dependenacy by skipping import at runtime 32 | from optimizely.project_config import ProjectConfig 33 | from optimizely.optimizely_user_context import UserAttributes 34 | from optimizely.logger import Logger 35 | 36 | CUSTOM_ATTRIBUTE_FEATURE_TYPE: Final = 'custom' 37 | 38 | 39 | class EventFactory: 40 | """ EventFactory builds LogEvent object from a given UserEvent. 41 | This class serves to separate concerns between events in the SDK and the API used 42 | to record the events via the Optimizely Events API ("https://developers.optimizely.com/x/events/api/index.html") 43 | """ 44 | 45 | EVENT_ENDPOINTS: Final = { 46 | 'US': 'https://logx.optimizely.com/v1/events', 47 | 'EU': 'https://eu.logx.optimizely.com/v1/events' 48 | } 49 | HTTP_VERB: Final = 'POST' 50 | HTTP_HEADERS: Final = {'Content-Type': 'application/json'} 51 | ACTIVATE_EVENT_KEY: Final = 'campaign_activated' 52 | 53 | @classmethod 54 | def create_log_event( 55 | cls, 56 | user_events: Sequence[Optional[user_event.UserEvent]] | Optional[user_event.UserEvent], 57 | logger: Logger 58 | ) -> Optional[log_event.LogEvent]: 59 | """ Create LogEvent instance. 60 | 61 | Args: 62 | user_events: A single UserEvent instance or a list of UserEvent instances. 63 | logger: Provides a logger instance. 64 | 65 | Returns: 66 | LogEvent instance. 67 | """ 68 | 69 | if not isinstance(user_events, list): 70 | user_events = cast(List[Optional[user_event.UserEvent]], [user_events]) 71 | 72 | visitors = [] 73 | 74 | for event in user_events: 75 | visitor = cls._create_visitor(event, logger) 76 | 77 | if visitor: 78 | visitors.append(visitor) 79 | 80 | if len(visitors) == 0: 81 | return None 82 | 83 | first_event = user_events[0] 84 | 85 | if not first_event: 86 | return None 87 | 88 | user_context = first_event.event_context 89 | event_batch = payload.EventBatch( 90 | user_context.account_id, 91 | user_context.project_id, 92 | user_context.revision, 93 | user_context.client_name, 94 | user_context.client_version, 95 | user_context.anonymize_ip, 96 | True, 97 | ) 98 | 99 | event_batch.visitors = visitors 100 | 101 | event_params = event_batch.get_event_params() 102 | 103 | region = user_context.region or 'US' # Default to 'US' if None 104 | region_key = str(region).upper() 105 | endpoint = cls.EVENT_ENDPOINTS.get(region_key, cls.EVENT_ENDPOINTS['US']) 106 | 107 | return log_event.LogEvent(endpoint, event_params, cls.HTTP_VERB, cls.HTTP_HEADERS) 108 | 109 | @classmethod 110 | def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger) -> Optional[payload.Visitor]: 111 | """ Helper method to create Visitor instance for event_batch. 112 | 113 | Args: 114 | event: Instance of UserEvent. 115 | logger: Provides a logger instance. 116 | 117 | Returns: 118 | Instance of Visitor. None if: 119 | - event is invalid. 120 | """ 121 | 122 | if isinstance(event, user_event.ImpressionEvent): 123 | experiment_layerId, experiment_id, variation_id, variation_key = '', '', '', '' 124 | 125 | if isinstance(event.variation, entities.Variation): 126 | variation_id = event.variation.id 127 | variation_key = event.variation.key 128 | 129 | if event.experiment: 130 | experiment_layerId = event.experiment.layerId 131 | experiment_id = event.experiment.id 132 | 133 | metadata = payload.Metadata(event.flag_key, event.rule_key, 134 | event.rule_type, variation_key, 135 | event.enabled, event.cmab_uuid) 136 | decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata) 137 | snapshot_event = payload.SnapshotEvent( 138 | experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp, 139 | ) 140 | 141 | snapshot = payload.Snapshot([snapshot_event], [decision]) 142 | 143 | visitor = payload.Visitor([snapshot], event.visitor_attributes, event.user_id) 144 | 145 | return visitor 146 | 147 | elif isinstance(event, user_event.ConversionEvent) and event.event: 148 | revenue = event_tag_utils.get_revenue_value(event.event_tags) 149 | value = event_tag_utils.get_numeric_value(event.event_tags, logger) 150 | 151 | snapshot_event = payload.SnapshotEvent( 152 | event.event.id, event.uuid, event.event.key, event.timestamp, revenue, value, event.event_tags, 153 | ) 154 | 155 | snapshot = payload.Snapshot([snapshot_event]) 156 | 157 | visitor = payload.Visitor([snapshot], event.visitor_attributes, event.user_id) 158 | 159 | return visitor 160 | 161 | else: 162 | logger.error('Invalid user event.') 163 | return None 164 | 165 | @staticmethod 166 | def build_attribute_list( 167 | attributes: Optional[UserAttributes], project_config: ProjectConfig 168 | ) -> list[payload.VisitorAttribute]: 169 | """ Create Vistor Attribute List. 170 | 171 | Args: 172 | attributes: Dict representing user attributes and values which need to be recorded or None. 173 | project_config: Instance of ProjectConfig. 174 | 175 | Returns: 176 | List consisting of valid attributes for the user. Empty otherwise. 177 | """ 178 | 179 | attributes_list: list[payload.VisitorAttribute] = [] 180 | 181 | if project_config is None: 182 | return attributes_list 183 | 184 | if isinstance(attributes, dict): 185 | for attribute_key in attributes.keys(): 186 | attribute_value = attributes.get(attribute_key) 187 | # Omit attribute values that are not supported by the log endpoint. 188 | if validator.is_attribute_valid(attribute_key, attribute_value): 189 | attribute_id = project_config.get_attribute_id(attribute_key) 190 | if attribute_id: 191 | attributes_list.append( 192 | payload.VisitorAttribute( 193 | attribute_id, attribute_key, CUSTOM_ATTRIBUTE_FEATURE_TYPE, attribute_value, 194 | ) 195 | ) 196 | 197 | # Append Bot Filtering Attribute 198 | bot_filtering_value = project_config.get_bot_filtering_value() 199 | if isinstance(bot_filtering_value, bool): 200 | attributes_list.append( 201 | payload.VisitorAttribute( 202 | enums.ControlAttributes.BOT_FILTERING, 203 | enums.ControlAttributes.BOT_FILTERING, 204 | CUSTOM_ATTRIBUTE_FEATURE_TYPE, 205 | bot_filtering_value, 206 | ) 207 | ) 208 | 209 | return attributes_list 210 | --------------------------------------------------------------------------------