diff --git a/devcycle_python_sdk/models/user.py b/devcycle_python_sdk/models/user.py index 3110ead..2afda96 100644 --- a/devcycle_python_sdk/models/user.py +++ b/devcycle_python_sdk/models/user.py @@ -1,7 +1,7 @@ # ruff: noqa: N815 from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, cast from openfeature.evaluation_context import EvaluationContext from openfeature.exception import TargetingKeyMissingError, InvalidContextError @@ -114,10 +114,10 @@ def create_user_from_context( user_id = context.targeting_key user_id_source = "targeting_key" elif context.attributes and "user_id" in context.attributes.keys(): - user_id = context.attributes["user_id"] + user_id = cast(str, context.attributes["user_id"]) user_id_source = "user_id" elif context.attributes and "userId" in context.attributes.keys(): - user_id = context.attributes["userId"] + user_id = cast(str, context.attributes["userId"]) user_id_source = "userId" if not user_id: diff --git a/devcycle_python_sdk/open_feature_provider/provider.py b/devcycle_python_sdk/open_feature_provider/provider.py index aad750c..6ce417c 100644 --- a/devcycle_python_sdk/open_feature_provider/provider.py +++ b/devcycle_python_sdk/open_feature_provider/provider.py @@ -1,7 +1,7 @@ import logging import time -from typing import Any, Optional, Union, List +from typing import Any, Optional, Union, List, Mapping, Sequence from devcycle_python_sdk import AbstractDevCycleClient from devcycle_python_sdk.models.user import DevCycleUser @@ -9,7 +9,7 @@ from openfeature.provider import AbstractProvider from openfeature.provider.metadata import Metadata from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.flag_evaluation import FlagResolutionDetails, Reason, FlagValueType from openfeature.exception import ( ErrorCode, InvalidContextError, @@ -138,10 +138,12 @@ def resolve_float_details( def resolve_object_details( self, flag_key: str, - default_value: Union[dict, list], + default_value: Union[Mapping[str, FlagValueType], Sequence[FlagValueType]], evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[Union[dict, list]]: - if not isinstance(default_value, dict): + ) -> FlagResolutionDetails[ + Union[Mapping[str, FlagValueType], Sequence[FlagValueType]] + ]: + if not isinstance(default_value, Mapping): raise TypeMismatchError("Default value must be a flat dictionary") if default_value: diff --git a/example/django-app/config/settings.py b/example/django-app/config/settings.py index efd931b..8ae76cd 100644 --- a/example/django-app/config/settings.py +++ b/example/django-app/config/settings.py @@ -16,7 +16,6 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ @@ -34,7 +33,6 @@ } } - # Application definition INSTALLED_APPS = [ @@ -78,7 +76,6 @@ WSGI_APPLICATION = "config.wsgi.application" - # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases @@ -89,7 +86,6 @@ } } - # Password validation # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators @@ -108,7 +104,6 @@ }, ] - # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ @@ -120,7 +115,6 @@ USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ diff --git a/example/django-app/db.sqlite3 b/example/django-app/db.sqlite3 new file mode 100644 index 0000000..a1afe5e Binary files /dev/null and b/example/django-app/db.sqlite3 differ diff --git a/example/openfeature_example.py b/example/openfeature_example.py index 358bfd1..0cc2b2f 100644 --- a/example/openfeature_example.py +++ b/example/openfeature_example.py @@ -7,19 +7,32 @@ from openfeature import api from openfeature.evaluation_context import EvaluationContext -FLAG_KEY = "test-boolean-variable" - logger = logging.getLogger(__name__) def main(): """ Sample usage of the DevCycle OpenFeature Provider along with the Python Server SDK using Local Bucketing. + + This example demonstrates how to use all variable types supported by DevCycle through OpenFeature: + - Boolean variables + - String variables + - Number variables (integer and float) + - JSON object variables + + See DEVCYCLE_SETUP.md for instructions on creating the required variables in DevCycle. """ logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s") # create an instance of the DevCycle Client object - server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"] + server_sdk_key = os.environ.get("DEVCYCLE_SERVER_SDK_KEY") + if not server_sdk_key: + logger.error("DEVCYCLE_SERVER_SDK_KEY environment variable is not set") + logger.error( + "Please set it with: export DEVCYCLE_SERVER_SDK_KEY='your-sdk-key'" + ) + exit(1) + devcycle_client = DevCycleLocalClient(server_sdk_key, DevCycleLocalOptions()) # Wait for DevCycle to initialize and load the configuration @@ -32,6 +45,8 @@ def main(): logger.error("DevCycle failed to initialize") exit(1) + logger.info("DevCycle initialized successfully!\n") + # set the provider for OpenFeature api.set_provider(devcycle_client.get_openfeature_provider()) @@ -53,22 +68,168 @@ def main(): }, ) - # Look up the value of the flag - if open_feature_client.get_boolean_value(FLAG_KEY, False, context): - logger.info(f"Variable {FLAG_KEY} is enabled") + logger.info("=" * 60) + logger.info("Testing Boolean Variable") + logger.info("=" * 60) + + # Test Boolean Variable + boolean_details = open_feature_client.get_boolean_details( + "test-boolean-variable", False, context + ) + logger.info("Variable Key: test-boolean-variable") + logger.info("Value: {boolean_details.value}") + logger.info("Reason: {boolean_details.reason}") + if boolean_details.value: + logger.info("✓ Boolean variable is ENABLED") else: - logger.info(f"Variable {FLAG_KEY} is not enabled") + logger.info("✗ Boolean variable is DISABLED") + + logger.info("\n" + "=" * 60) + logger.info("Testing String Variable") + logger.info("=" * 60) - # Fetch a JSON object variable - json_object = open_feature_client.get_object_value( + # Test String Variable + open_feature_client.get_string_details( + "test-string-variable", "default string", context + ) + logger.info("Variable Key: test-string-variable") + logger.info("Value: {string_details.value}") + logger.info("Reason: {string_details.reason}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Number Variable (Integer)") + logger.info("=" * 60) + + # Test Number Variable (Integer) + open_feature_client.get_integer_details("test-number-variable", 0, context) + logger.info("Variable Key: test-number-variable") + logger.info("Value: {integer_details.value}") + logger.info("Reason: {integer_details.reason}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Number Variable (Float)") + logger.info("=" * 60) + + # Test Number Variable as Float + # Note: If the DevCycle variable is an integer, it will be cast to float + open_feature_client.get_float_value("test-number-variable", 0.0, context) + logger.info("Variable Key: test-number-variable (as float)") + logger.info("Value: {float_value}") + + logger.info("\n" + "=" * 60) + logger.info("Testing JSON Object Variable") + logger.info("=" * 60) + + # Test JSON Object Variable + open_feature_client.get_object_details( "test-json-variable", {"default": "value"}, context ) - logger.info(f"JSON Object Value: {json_object}") + logger.info("Variable Key: test-json-variable") + logger.info("Value: {json_details.value}") + logger.info("Reason: {json_details.reason}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - Empty Dictionary") + logger.info("=" * 60) + + # Test with empty dictionary default (valid per OpenFeature spec) + open_feature_client.get_object_value("test-json-variable", {}, context) + logger.info("Variable Key: test-json-variable (with empty default)") + logger.info("Value: {empty_dict_result}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - Mixed Types") + logger.info("=" * 60) + + # Test with flat dictionary containing mixed primitive types + # OpenFeature allows string, int, float, bool, and None in flat dictionaries + mixed_default = { + "string_key": "hello", + "int_key": 42, + "float_key": 3.14, + "bool_key": True, + "none_key": None, + } + mixed_result = open_feature_client.get_object_value( + "test-json-variable", mixed_default, context + ) + logger.info("Variable Key: test-json-variable (with mixed types)") + logger.info("Value: {mixed_result}") + logger.info( + f"Value types: {[(k, type(v).__name__) for k, v in mixed_result.items()]}" + ) + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - All String Values") + logger.info("=" * 60) + + # Test with all string values + string_dict_default = { + "name": "John Doe", + "email": "john@example.com", + "status": "active", + } + open_feature_client.get_object_value( + "test-json-variable", string_dict_default, context + ) + logger.info("Variable Key: test-json-variable (all strings)") + logger.info("Value: {string_dict_result}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - Numeric Values") + logger.info("=" * 60) + + # Test with numeric values (integers and floats) + numeric_dict_default = {"count": 100, "percentage": 85.5, "threshold": 0} + open_feature_client.get_object_value( + "test-json-variable", numeric_dict_default, context + ) + logger.info("Variable Key: test-json-variable (numeric)") + logger.info("Value: {numeric_dict_result}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - Boolean Flags") + logger.info("=" * 60) + + # Test with boolean values + bool_dict_default = {"feature_a": True, "feature_b": False, "feature_c": True} + open_feature_client.get_object_value( + "test-json-variable", bool_dict_default, context + ) + logger.info("Variable Key: test-json-variable (booleans)") + logger.info("Value: {bool_dict_result}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Object Variable - With None Values") + logger.info("=" * 60) + + # Test with None values (valid per OpenFeature spec for flat dictionaries) + none_dict_default = { + "optional_field": None, + "required_field": "value", + "nullable_count": None, + } + open_feature_client.get_object_value( + "test-json-variable", none_dict_default, context + ) + logger.info("Variable Key: test-json-variable (with None)") + logger.info("Value: {none_dict_result}") + + logger.info("\n" + "=" * 60) + logger.info("Testing Non-Existent Variable (Should Return Default)") + logger.info("=" * 60) + + # Test non-existent variable to demonstrate default handling + open_feature_client.get_string_details( + "doesnt-exist", "default fallback value", context + ) + logger.info("Variable Key: doesnt-exist") + logger.info("Value: {nonexistent_details.value}") + logger.info("Reason: {nonexistent_details.reason}") - # Retrieve a string variable along with resolution details - details = open_feature_client.get_string_details("doesnt-exist", "default", context) - logger.info(f"String Value: {details.value}") - logger.info(f"Eval Reason: {details.reason}") + logger.info("\n" + "=" * 60) + logger.info("All tests completed!") + logger.info("=" * 60) devcycle_client.close() diff --git a/requirements.txt b/requirements.txt index 3645631..1054d22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ urllib3 >= 1.15.1 requests >= 2.32 wasmtime ~= 30.0.0 protobuf >= 4.23.3 -openfeature-sdk == 0.8.0 +openfeature-sdk ~= 0.8.0 launchdarkly-eventsource >= 1.2.1 responses >= 0.23.1 \ No newline at end of file