From 9ca0f08de1ebf59c070f524a0a43b6579feca8cb Mon Sep 17 00:00:00 2001 From: Pijush Chakraborty Date: Fri, 15 Nov 2024 21:51:00 +0530 Subject: [PATCH 1/7] Implementation for Fetching and Caching Server Side Remote Config (#825) * Initial Skeleton for SSRC Implementation * Adding Implementation for RemoteConfigApiClient and ServerTemplate APIs * Updating API signature * Minor update to API signature * Adding comments and unit tests * Updating init params for ServerTemplateData * Adding validation errors and test * Adding unit tests for init_server_template and get_server_template * Removing parameter groups * Addressing PR comments and fixing async flow during fetch call * Fixing lint issues --------- Co-authored-by: Pijush Chakraborty --- firebase_admin/remote_config.py | 231 ++++++++++++++++++++++++++++++++ tests/test_remote_config.py | 148 ++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 firebase_admin/remote_config.py create mode 100644 tests/test_remote_config.py diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py new file mode 100644 index 00000000..cd7d17f6 --- /dev/null +++ b/firebase_admin/remote_config.py @@ -0,0 +1,231 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase Remote Config Module. +This module has required APIs for the clients to use Firebase Remote Config with python. +""" + +import asyncio +from typing import Any, Dict, Optional +import requests +from firebase_admin import App, _http_client, _utils +import firebase_admin + +_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig' + +class ServerTemplateData: + """Parses, validates and encapsulates template data and metadata.""" + def __init__(self, etag, template_data): + """Initializes a new ServerTemplateData instance. + + Args: + etag: The string to be used for initialize the ETag property. + template_data: The data to be parsed for getting the parameters and conditions. + + Raises: + ValueError: If the template data is not valid. + """ + if 'parameters' in template_data: + if template_data['parameters'] is not None: + self._parameters = template_data['parameters'] + else: + raise ValueError('Remote Config parameters must be a non-null object') + else: + self._parameters = {} + + if 'conditions' in template_data: + if template_data['conditions'] is not None: + self._conditions = template_data['conditions'] + else: + raise ValueError('Remote Config conditions must be a non-null object') + else: + self._conditions = [] + + self._version = '' + if 'version' in template_data: + self._version = template_data['version'] + + self._etag = '' + if etag is not None and isinstance(etag, str): + self._etag = etag + + @property + def parameters(self): + return self._parameters + + @property + def etag(self): + return self._etag + + @property + def version(self): + return self._version + + @property + def conditions(self): + return self._conditions + + +class ServerTemplate: + """Represents a Server Template with implementations for loading and evaluting the template.""" + def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None): + """Initializes a ServerTemplate instance. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + """ + self._rc_service = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + # This gets set when the template is + # fetched from RC servers via the load API, or via the set API. + self._cache = None + self._stringified_default_config: Dict[str, str] = {} + + # RC stores all remote values as string, but it's more intuitive + # to declare default values with specific types, so this converts + # the external declaration to an internal string representation. + if default_config is not None: + for key in default_config: + self._stringified_default_config[key] = str(default_config[key]) + + async def load(self): + """Fetches the server template and caches the data.""" + self._cache = await self._rc_service.get_server_template() + + def evaluate(self): + # Logic to process the cached template into a ServerConfig here. + # TODO: Add and validate Condition evaluator. + self._evaluator = _ConditionEvaluator(self._cache.parameters) + return ServerConfig(config_values=self._evaluator.evaluate()) + + def set(self, template: ServerTemplateData): + """Updates the cache to store the given template is of type ServerTemplateData. + + Args: + template: An object of type ServerTemplateData to be cached. + """ + self._cache = template + + +class ServerConfig: + """Represents a Remote Config Server Side Config.""" + def __init__(self, config_values): + self._config_values = config_values # dictionary of param key to values + + def get_boolean(self, key): + return bool(self.get_value(key)) + + def get_string(self, key): + return str(self.get_value(key)) + + def get_int(self, key): + return int(self.get_value(key)) + + def get_value(self, key): + return self._config_values[key] + + +class _RemoteConfigService: + """Internal class that facilitates sending requests to the Firebase Remote + Config backend API. + """ + def __init__(self, app): + """Initialize a JsonHttpClient with necessary inputs. + + Args: + app: App instance to be used for fetching app specific details required + for initializing the http client. + """ + remote_config_base_url = 'https://firebaseremoteconfig.googleapis.com' + self._project_id = app.project_id + app_credential = app.credential.get_credential() + rc_headers = { + 'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__), } + timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) + + self._client = _http_client.JsonHttpClient(credential=app_credential, + base_url=remote_config_base_url, + headers=rc_headers, timeout=timeout) + + async def get_server_template(self): + """Requests for a server template and converts the response to an instance of + ServerTemplateData for storing the template parameters and conditions.""" + try: + loop = asyncio.get_event_loop() + headers, template_data = await loop.run_in_executor(None, + self._client.headers_and_body, + 'get', self._get_url()) + except requests.exceptions.RequestException as error: + raise self._handle_remote_config_error(error) + else: + return ServerTemplateData(headers.get('etag'), template_data) + + def _get_url(self): + """Returns project prefix for url, in the format of /v1/projects/${projectId}""" + return "/v1/projects/{0}/namespaces/firebase-server/serverRemoteConfig".format( + self._project_id) + + @classmethod + def _handle_remote_config_error(cls, error: Any): + """Handles errors received from the Cloud Functions API.""" + return _utils.handle_platform_error_from_requests(error) + + +class _ConditionEvaluator: + """Internal class that facilitates sending requests to the Firebase Remote + Config backend API.""" + def __init__(self, parameters): + self._parameters = parameters + + def evaluate(self): + # TODO: Write logic for evaluator + return self._parameters + + +async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None): + """Initializes a new ServerTemplate instance and fetches the server template. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + + Returns: + ServerTemplate: An object having the cached server template to be used for evaluation. + """ + template = init_server_template(app=app, default_config=default_config) + await template.load() + return template + +def init_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None, + template_data: Optional[ServerTemplateData] = None): + """Initializes a new ServerTemplate instance. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + template_data: An optional template data to be set on initialization. + + Returns: + ServerTemplate: A new ServerTemplate instance initialized with an optional + template and config. + """ + template = ServerTemplate(app=app, default_config=default_config) + if template_data is not None: + template.set(template_data) + return template diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py new file mode 100644 index 00000000..6b0b171c --- /dev/null +++ b/tests/test_remote_config.py @@ -0,0 +1,148 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for firebase_admin.remote_config.""" +import json +import pytest +import firebase_admin +from firebase_admin import remote_config +from firebase_admin.remote_config import _REMOTE_CONFIG_ATTRIBUTE +from firebase_admin.remote_config import _RemoteConfigService, ServerTemplateData + +from firebase_admin import _utils +from tests import testutils + +class MockAdapter(testutils.MockAdapter): + """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" + + ETAG = 'etag' + + def __init__(self, data, status, recorder, etag=ETAG): + testutils.MockAdapter.__init__(self, data, status, recorder) + self._etag = etag + + def send(self, request, **kwargs): + resp = super(MockAdapter, self).send(request, **kwargs) + resp.headers = {'etag': self._etag} + return resp + + +class TestRemoteConfigService: + """Tests methods on _RemoteConfigService""" + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + @pytest.mark.asyncio + async def test_rc_instance_get_server_template(self): + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': 'test_value' + }, + 'conditions': [], + 'version': 'test' + }) + + rc_instance = _utils.get_app_service(firebase_admin.get_app(), + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await rc_instance.get_server_template() + + assert template.parameters == dict(test_key="test_value") + assert str(template.version) == 'test' + assert str(template.etag) == 'etag' + + @pytest.mark.asyncio + async def test_rc_instance_get_server_template_empty_params(self): + recorder = [] + response = json.dumps({ + 'conditions': [], + 'version': 'test' + }) + + rc_instance = _utils.get_app_service(firebase_admin.get_app(), + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await rc_instance.get_server_template() + + assert template.parameters == {} + assert str(template.version) == 'test' + assert str(template.etag) == 'etag' + + +class TestRemoteConfigModule: + """Tests methods on firebase_admin.remote_config""" + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_init_server_template(self): + app = firebase_admin.get_app() + template_data = { + 'conditions': [], + 'parameters': { + 'test_key': 'test_value' + }, + 'version': '', + } + + template = remote_config.init_server_template( + app=app, + default_config={'default_test': 'default_value'}, + template_data=ServerTemplateData('etag', template_data) + ) + + config = template.evaluate() + assert config.get_string('test_key') == 'test_value' + + @pytest.mark.asyncio + async def test_get_server_template(self): + app = firebase_admin.get_app() + rc_instance = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': 'test_value' + }, + 'conditions': [], + 'version': 'test' + }) + + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await remote_config.get_server_template(app=app) + + config = template.evaluate() + assert config.get_string('test_key') == 'test_value' From 345412b5b603ccc7f90f276de0e3f9de8103763b Mon Sep 17 00:00:00 2001 From: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Date: Sat, 16 Nov 2024 01:41:20 +0530 Subject: [PATCH 2/7] Implementation for Evaluating Condition and Custom Signals Server Side Remote Config (#824) * Added implemenation of evaluate function * Improvement * Add farmhash to extension whitelist pkg * Replace farmhash to hashlib * Added unit testcase * removed lint error * add mock test * resolve lint comments * Fixed bug * Added fixes * Added fixe * Added fix for lint * Changed structure of test * Added fix for comments * Added fix for comments --------- Co-authored-by: Varun Rathore --- firebase_admin/remote_config.py | 492 ++++++++++++++++++++- tests/test_remote_config.py | 748 +++++++++++++++++++++++++++++++- tests/testutils.py | 40 ++ 3 files changed, 1261 insertions(+), 19 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index cd7d17f6..c975b7c1 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -17,12 +17,51 @@ """ import asyncio -from typing import Any, Dict, Optional +import logging +from typing import Dict, Optional, Literal, Union, Any +from enum import Enum +import re +import hashlib import requests from firebase_admin import App, _http_client, _utils import firebase_admin +# Set up logging (you can customize the level and output) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + _REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig' +MAX_CONDITION_RECURSION_DEPTH = 10 +ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type + +class PercentConditionOperator(Enum): + """Enum representing the available operators for percent conditions. + """ + LESS_OR_EQUAL = "LESS_OR_EQUAL" + GREATER_THAN = "GREATER_THAN" + BETWEEN = "BETWEEN" + UNKNOWN = "UNKNOWN" + +class CustomSignalOperator(Enum): + """Enum representing the available operators for custom signal conditions. + """ + STRING_CONTAINS = "STRING_CONTAINS" + STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN" + STRING_EXACTLY_MATCHES = "STRING_EXACTLY_MATCHES" + STRING_CONTAINS_REGEX = "STRING_CONTAINS_REGEX" + NUMERIC_LESS_THAN = "NUMERIC_LESS_THAN" + NUMERIC_LESS_EQUAL = "NUMERIC_LESS_EQUAL" + NUMERIC_EQUAL = "NUMERIC_EQUAL" + NUMERIC_NOT_EQUAL = "NUMERIC_NOT_EQUAL" + NUMERIC_GREATER_THAN = "NUMERIC_GREATER_THAN" + NUMERIC_GREATER_EQUAL = "NUMERIC_GREATER_EQUAL" + SEMANTIC_VERSION_LESS_THAN = "SEMANTIC_VERSION_LESS_THAN" + SEMANTIC_VERSION_LESS_EQUAL = "SEMANTIC_VERSION_LESS_EQUAL" + SEMANTIC_VERSION_EQUAL = "SEMANTIC_VERSION_EQUAL" + SEMANTIC_VERSION_NOT_EQUAL = "SEMANTIC_VERSION_NOT_EQUAL" + SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN" + SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" + UNKNOWN = "UNKNOWN" class ServerTemplateData: """Parses, validates and encapsulates template data and metadata.""" @@ -89,7 +128,6 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N """ self._rc_service = _utils.get_app_service(app, _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) - # This gets set when the template is # fetched from RC servers via the load API, or via the set API. self._cache = None @@ -106,10 +144,30 @@ async def load(self): """Fetches the server template and caches the data.""" self._cache = await self._rc_service.get_server_template() - def evaluate(self): + def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig': + """Evaluates the cached server template to produce a ServerConfig. + + Args: + context: A dictionary of values to use for evaluating conditions. + + Returns: + A ServerConfig object. + Raises: + ValueError: If the input arguments are invalid. + """ # Logic to process the cached template into a ServerConfig here. - # TODO: Add and validate Condition evaluator. - self._evaluator = _ConditionEvaluator(self._cache.parameters) + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling evaluate().""") + context = context or {} + config_values = {} + # Initializes config Value objects with default values. + if self._stringified_default_config is not None: + for key, value in self._stringified_default_config.items(): + config_values[key] = _Value('default', value) + self._evaluator = _ConditionEvaluator(self._cache.conditions, + self._cache.parameters, context, + config_values) return ServerConfig(config_values=self._evaluator.evaluate()) def set(self, template: ServerTemplateData): @@ -127,16 +185,21 @@ def __init__(self, config_values): self._config_values = config_values # dictionary of param key to values def get_boolean(self, key): - return bool(self.get_value(key)) + return self.get_value(key).as_boolean() def get_string(self, key): - return str(self.get_value(key)) + return self.get_value(key).as_string() def get_int(self, key): - return int(self.get_value(key)) + return self.get_value(key).as_int() + + def get_float(self, key): + return self.get_value(key).as_float() def get_value(self, key): - return self._config_values[key] + if self._config_values[key]: + return self._config_values[key] + return _Value('static') class _RemoteConfigService: @@ -188,13 +251,371 @@ def _handle_remote_config_error(cls, error: Any): class _ConditionEvaluator: """Internal class that facilitates sending requests to the Firebase Remote Config backend API.""" - def __init__(self, parameters): + def __init__(self, conditions, parameters, context, config_values): + self._context = context + self._conditions = conditions self._parameters = parameters + self._config_values = config_values def evaluate(self): - # TODO: Write logic for evaluator - return self._parameters + """Internal function Evaluates the cached server template to produce + a ServerConfig""" + evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) + + # Overlays config Value objects derived by evaluating the template. + if self._parameters: + for key, parameter in self._parameters.items(): + conditional_values = parameter.get('conditionalValues', {}) + default_value = parameter.get('defaultValue', {}) + parameter_value_wrapper = None + # Iterates in order over condition list. If there is a value associated + # with a condition, this checks if the condition is true. + if evaluated_conditions: + for condition_name, condition_evaluation in evaluated_conditions.items(): + if condition_name in conditional_values and condition_evaluation: + parameter_value_wrapper = conditional_values[condition_name] + break + + if parameter_value_wrapper and parameter_value_wrapper.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + + if parameter_value_wrapper: + parameter_value = parameter_value_wrapper.get('value') + self._config_values[key] = _Value('remote', parameter_value) + continue + + if not default_value: + logger.warning("No default value found for key '%s'", key) + continue + + if default_value.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + self._config_values[key] = _Value('remote', default_value.get('value')) + return self._config_values + + def evaluate_conditions(self, conditions, context)-> Dict[str, bool]: + """Evaluates a list of conditions and returns a dictionary of results. + + Args: + conditions: A list of NamedCondition objects. + context: An EvaluationContext object. + + Returns: + A dictionary mapping condition names to boolean evaluation results. + """ + evaluated_conditions = {} + for condition in conditions: + evaluated_conditions[condition.get('name')] = self.evaluate_condition( + condition.get('condition'), context + ) + return evaluated_conditions + + def evaluate_condition(self, condition, context, + nesting_level: int = 0) -> bool: + """Recursively evaluates a condition. + + Args: + condition: The condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + The boolean result of the condition evaluation. + """ + if nesting_level >= MAX_CONDITION_RECURSION_DEPTH: + logger.warning("Maximum condition recursion depth exceeded.") + return False + if condition.get('orCondition') is not None: + return self.evaluate_or_condition(condition.get('orCondition'), + context, nesting_level + 1) + if condition.get('andCondition') is not None: + return self.evaluate_and_condition(condition.get('andCondition'), + context, nesting_level + 1) + if condition.get('true') is not None: + return True + if condition.get('false') is not None: + return False + if condition.get('percent') is not None: + return self.evaluate_percent_condition(condition.get('percent'), context) + if condition.get('customSignal') is not None: + return self.evaluate_custom_signal_condition(condition.get('customSignal'), context) + logger.warning("Unknown condition type encountered.") + return False + + def evaluate_or_condition(self, or_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an OR condition. + + Args: + or_condition: The OR condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if any of the subconditions are true, False otherwise. + """ + sub_conditions = or_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if result: + return True + return False + + def evaluate_and_condition(self, and_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an AND condition. + + Args: + and_condition: The AND condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if all of the subconditions are true, False otherwise. + """ + sub_conditions = and_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if not result: + return False + return True + + def evaluate_percent_condition(self, percent_condition, + context) -> bool: + """Evaluates a percent condition. + + Args: + percent_condition: The percent condition to evaluate. + context: An EvaluationContext object. + Returns: + True if the condition is met, False otherwise. + """ + if not context.get('randomization_id'): + logger.warning("Missing randomization ID for percent condition.") + return False + + seed = percent_condition.get('seed') + percent_operator = percent_condition.get('percentOperator') + micro_percent = percent_condition.get('microPercent') + micro_percent_range = percent_condition.get('microPercentRange') + if not percent_operator: + logger.warning("Missing percent operator for percent condition.") + return False + if micro_percent_range: + norm_percent_upper_bound = micro_percent_range.get('microPercentUpperBound') + norm_percent_lower_bound = micro_percent_range.get('microPercentLowerBound') + else: + norm_percent_upper_bound = 0 + norm_percent_lower_bound = 0 + if micro_percent: + norm_micro_percent = micro_percent + else: + norm_micro_percent = 0 + seed_prefix = f"{seed}." if seed else "" + string_to_hash = f"{seed_prefix}{context.get('randomization_id')}" + + hash64 = self.hash_seeded_randomization_id(string_to_hash) + instance_micro_percentile = hash64 % (100 * 1000000) + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL: + return instance_micro_percentile <= norm_micro_percent + if percent_operator == PercentConditionOperator.GREATER_THAN: + return instance_micro_percentile > norm_micro_percent + if percent_operator == PercentConditionOperator.BETWEEN: + return norm_percent_lower_bound < instance_micro_percentile <= norm_percent_upper_bound + logger.warning("Unknown percent operator: %s", percent_operator) + return False + def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: + """Hashes a seeded randomization ID. + + Args: + seeded_randomization_id: The seeded randomization ID to hash. + + Returns: + The hashed value. + """ + hash_object = hashlib.sha256() + hash_object.update(seeded_randomization_id.encode('utf-8')) + hash64 = hash_object.hexdigest() + return abs(int(hash64, 16)) + + def evaluate_custom_signal_condition(self, custom_signal_condition, + context) -> bool: + """Evaluates a custom signal condition. + + Args: + custom_signal_condition: The custom signal condition to evaluate. + context: An EvaluationContext object. + + Returns: + True if the condition is met, False otherwise. + """ + custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} + custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} + target_custom_signal_values = ( + custom_signal_condition.get('target_custom_signal_values') or {}) + + if not all([custom_signal_operator, custom_signal_key, target_custom_signal_values]): + logger.warning("Missing operator, key, or target values for custom signal condition.") + return False + + if not target_custom_signal_values: + return False + actual_custom_signal_value = context.get(custom_signal_key) or {} + + if not actual_custom_signal_value: + logger.warning("Custom signal value not found in context: %s", custom_signal_key) + return False + + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + return not self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target.strip() == actual.strip()) + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + re.search) + + # For numeric operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + return self._compare_numbers(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + + # For semantic operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + return self._compare_semantic_versions(target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + return False + + def _compare_strings(self, target_values, actual_value, predicate_fn) -> bool: + """Compares the actual string value of a signal against a list of target values. + + Args: + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes two string arguments (target and actual) + and returns a boolean indicating whether + the target matches the actual value. + + Returns: + bool: True if the predicate function returns True for any target value in the list, + False otherwise. + """ + + for target in target_values: + if predicate_fn(target, str(actual_value)): + return True + return False + + def _compare_numbers(self, target_value, actual_value, predicate_fn) -> bool: + try: + target = float(target_value) + actual = float(actual_value) + result = -1 if actual < target else 1 if actual > target else 0 + return predicate_fn(result) + except ValueError: + logger.warning("Invalid numeric value for comparison.") + return False + + def _compare_semantic_versions(self, target_value, actual_value, predicate_fn) -> bool: + """Compares the actual semantic version value of a signal against a target value. + Calls the predicate function with -1, 0, 1 if actual is less than, equal to, + or greater than target. + + Args: + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes an integer (-1, 0, or 1) and returns a boolean. + + Returns: + bool: True if the predicate function returns True for the result of the comparison, + False otherwise. + """ + return self._compare_versions(str(actual_value), + str(target_value), predicate_fn) + + def _compare_versions(self, version1, version2, predicate_fn) -> bool: + """Compares two semantic version strings. + + Args: + version1: The first semantic version string. + version2: The second semantic version string. + predicate_fn: A function that takes an integer and returns a boolean. + + Returns: + bool: The result of the predicate function. + """ + try: + v1_parts = [int(part) for part in version1.split('.')] + v2_parts = [int(part) for part in version2.split('.')] + max_length = max(len(v1_parts), len(v2_parts)) + v1_parts.extend([0] * (max_length - len(v1_parts))) + v2_parts.extend([0] * (max_length - len(v2_parts))) + + for part1, part2 in zip(v1_parts, v2_parts): + if part1 < part2: + return predicate_fn(-1) + if part1 > part2: + return predicate_fn(1) + return predicate_fn(0) + except ValueError: + logger.warning("Invalid semantic version format for comparison.") + return False async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None): """Initializes a new ServerTemplate instance and fetches the server template. @@ -229,3 +650,50 @@ def init_server_template(app: App = None, default_config: Optional[Dict[str, str if template_data is not None: template.set(template_data) return template + +class _Value: + """Represents a value fetched from Remote Config. + """ + DEFAULT_VALUE_FOR_BOOLEAN = False + DEFAULT_VALUE_FOR_STRING = '' + DEFAULT_VALUE_FOR_INTEGER = 0 + DEFAULT_VALUE_FOR_FLOAT_NUMBER = 0.0 + BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on'] + + def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): + """Initializes a Value instance. + + Args: + source: The source of the value (e.g., 'default', 'remote', 'static'). + value: The string value. + """ + self.source = source + self.value = value + + def as_string(self) -> str: + """Returns the value as a string.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_STRING + return self.value + + def as_boolean(self) -> bool: + """Returns the value as a boolean.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_BOOLEAN + return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES + + def as_int(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_INTEGER + return self.value + + def as_float(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER + return float(self.value) + + def get_source(self) -> ValueSource: + """Returns the source of the value.""" + return self.source diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 6b0b171c..914b99cb 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -14,15 +14,743 @@ """Tests for firebase_admin.remote_config.""" import json +import uuid import pytest import firebase_admin -from firebase_admin import remote_config -from firebase_admin.remote_config import _REMOTE_CONFIG_ATTRIBUTE -from firebase_admin.remote_config import _RemoteConfigService, ServerTemplateData - -from firebase_admin import _utils +from firebase_admin.remote_config import ( + PercentConditionOperator, + _REMOTE_CONFIG_ATTRIBUTE, + _RemoteConfigService, + ServerTemplateData) +from firebase_admin import remote_config, _utils from tests import testutils +VERSION_INFO = { + 'versionNumber': '86', + 'updateOrigin': 'ADMIN_SDK_PYTHON', + 'updateType': 'INCREMENTAL_UPDATE', + 'updateUser': { + 'email': 'firebase-adminsdk@gserviceaccount.com' + }, + 'description': 'production version', + 'updateTime': '2024-11-05T16:45:03.541527Z' + } + +SERVER_REMOTE_CONFIG_RESPONSE = { + 'conditions': [ + { + 'name': 'ios', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + {'true': {}} + ] + } + } + ] + } + } + }, + ], + 'parameters': { + 'holiday_promo_enabled': { + 'defaultValue': {'value': 'true'}, + 'conditionalValues': {'ios': {'useInAppDefault': 'true'}} + }, + }, + 'parameterGroups': '', + 'etag': 'etag-123456789012-5', + 'version': VERSION_INFO, + } + +class TestEvaluate: + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_evaluate_or_and_true_condition_true(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'true': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_evaluate_or_and_false_condition_false(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'false': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_non_or_condition(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'true': { + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_evaluate_return_conditional_values_honor_order(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + template_data = { + 'conditions': [ + { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + }, + { + 'name': 'is_true_too', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + } + ], + 'parameters': { + 'dog_type': { + 'defaultValue': {'value': 'chihuahua'}, + 'conditionalValues': { + 'is_true_too': {'value': 'dachshund'}, + 'is_true': {'value': 'corgi'} + } + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'corgi' + + def test_evaluate_default_when_no_param(self): + app = firebase_admin.get_app() + default_config = {'promo_enabled': False, 'promo_discount': '20',} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') + assert server_config.get_int('promo_discount') == default_config.get('promo_discount') + + def test_evaluate_default_when_no_default_value(self): + app = firebase_admin.get_app() + default_config = {'default_value': 'local default'} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'default_value': {} + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('default_value') == default_config.get('default_value') + + def test_evaluate_default_when_in_default(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'remote_default_value': {} + } + default_config = { + 'inapp_default': '🐕' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('inapp_default') == default_config.get('inapp_default') + + def test_evaluate_default_when_defined(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + default_config = { + 'dog_type': 'shiba' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_value('dog_type').as_string() == 'shiba' + assert server_config.get_value('dog_type').get_source() == 'default' + + def test_evaluate_return_numeric_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_age': '12' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_int('dog_age') == default_config.get('dog_age') + + def test_evaluate_return_boolean_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_is_cute': True + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('dog_is_cute') + + def test_evaluate_unknown_operator_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.UNKNOWN + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'seed': 'abcdef', + 'microPercent': 100_000_000 + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercent_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercentrange_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_between_min_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 0, + 'microPercentUpperBound': 100_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_between_equal_bounds_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 50000000, + 'microPercentUpperBound': 50000000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'seed': 'abcdef', + 'microPercent': 10_000_000 # 10% + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 284 + assert truthy_assignments >= 10000 - tolerance + assert truthy_assignments <= 10000 + tolerance + + def test_evaluate_between_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 40_000_000, + 'microPercentUpperBound': 60_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 379 + assert truthy_assignments >= 20000 - tolerance + assert truthy_assignments <= 20000 + tolerance + + def test_evaluate_between_interquartile_range_accuracy(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 25_000_000, + 'microPercentUpperBound': 75_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 474 + assert truthy_assignments >= 50000 - tolerance + assert truthy_assignments <= 50000 + tolerance + + def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config): + """Evaluates random assignments based on a condition. + + Args: + condition: The condition to evaluate. + num_of_assignments: The number of assignments to generate. + condition_evaluator: An instance of the ConditionEvaluator class. + + Returns: + int: The number of assignments that evaluated to true. + """ + eval_true_count = 0 + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + + for _ in range(num_of_assignments): + context = {'randomization_id': str(uuid.uuid4())} + result = server_template.evaluate(context) + if result.get_boolean('is_enabled') is True: + eval_true_count += 1 + + return eval_true_count + + class MockAdapter(testutils.MockAdapter): """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" @@ -109,7 +837,10 @@ def test_init_server_template(self): template_data = { 'conditions': [], 'parameters': { - 'test_key': 'test_value' + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } }, 'version': '', } @@ -132,7 +863,10 @@ async def test_get_server_template(self): recorder = [] response = json.dumps({ 'parameters': { - 'test_key': 'test_value' + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } }, 'conditions': [], 'version': 'test' diff --git a/tests/testutils.py b/tests/testutils.py index ab4fb40c..17013b46 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -218,3 +218,43 @@ def send(self, request, **kwargs): # pylint: disable=arguments-differ resp.raw = io.BytesIO(response.encode()) break return resp + +def build_mock_condition(name, condition): + return { + 'name': name, + 'condition': condition, + } + +def build_mock_parameter(name, description, value=None, + conditional_values=None, default_value=None, parameter_groups=None): + return { + 'name': name, + 'description': description, + 'value': value, + 'conditionalValues': conditional_values, + 'defaultValue': default_value, + 'parameterGroups': parameter_groups, + } + +def build_mock_conditional_value(condition_name, value): + return { + 'conditionName': condition_name, + 'value': value, + } + +def build_mock_default_value(value): + return { + 'value': value, + } + +def build_mock_parameter_group(name, description, parameters): + return { + 'name': name, + 'description': description, + 'parameters': parameters, + } + +def build_mock_version(version_number): + return { + 'versionNumber': version_number, + } From ed0abbc439cf907bcded742bc627d057d3b113ce Mon Sep 17 00:00:00 2001 From: Pijush Chakraborty Date: Wed, 8 Jan 2025 23:42:41 +0530 Subject: [PATCH 3/7] Fixing SSRC Typos and Minor Bugs (#841) * Changes for percent comparison * Fixing semantic version issues with invalid version * Fixing Config values must retrun default values from invalid get operations * Updating tolerance for percentage evaluation * Removing dependency changes from fix branch * Updating ServerConfig methods as per review changes * Updating comments and vars for readability * Added unit and integration tests * Refactor and add unit test * Implementation for Fetching and Caching Server Side Remote Config (#825) * Minor update to API signature * Updating init params for ServerTemplateData * Adding validation errors and test * Removing parameter groups * Addressing PR comments and fixing async flow during fetch call * Fixing lint issues --------- Co-authored-by: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Co-authored-by: Lahiru Maramba Co-authored-by: Pijush Chakraborty Co-authored-by: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Co-authored-by: Varun Rathore --- firebase_admin/remote_config.py | 86 ++++++++++++++------------- tests/test_remote_config.py | 101 +++++++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 56 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index c975b7c1..119f41e3 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -1,4 +1,4 @@ -# Copyright 2017 Google Inc. +# Copyright 2024 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -185,21 +185,19 @@ def __init__(self, config_values): self._config_values = config_values # dictionary of param key to values def get_boolean(self, key): - return self.get_value(key).as_boolean() + return self._get_value(key).as_boolean() def get_string(self, key): - return self.get_value(key).as_string() + return self._get_value(key).as_string() def get_int(self, key): - return self.get_value(key).as_int() + return self._get_value(key).as_int() def get_float(self, key): - return self.get_value(key).as_float() + return self._get_value(key).as_float() - def get_value(self, key): - if self._config_values[key]: - return self._config_values[key] - return _Value('static') + def _get_value(self, key): + return self._config_values.get(key, _Value('static')) class _RemoteConfigService: @@ -421,11 +419,11 @@ def evaluate_percent_condition(self, percent_condition, hash64 = self.hash_seeded_randomization_id(string_to_hash) instance_micro_percentile = hash64 % (100 * 1000000) - if percent_operator == PercentConditionOperator.LESS_OR_EQUAL: + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL.value: return instance_micro_percentile <= norm_micro_percent - if percent_operator == PercentConditionOperator.GREATER_THAN: + if percent_operator == PercentConditionOperator.GREATER_THAN.value: return instance_micro_percentile > norm_micro_percent - if percent_operator == PercentConditionOperator.BETWEEN: + if percent_operator == PercentConditionOperator.BETWEEN.value: return norm_percent_lower_bound < instance_micro_percentile <= norm_percent_upper_bound logger.warning("Unknown percent operator: %s", percent_operator) return False @@ -454,10 +452,10 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, Returns: True if the condition is met, False otherwise. """ - custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} - custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} + custom_signal_operator = custom_signal_condition.get('customSignalOperator') or {} + custom_signal_key = custom_signal_condition.get('customSignalKey') or {} target_custom_signal_values = ( - custom_signal_condition.get('target_custom_signal_values') or {}) + custom_signal_condition.get('targetCustomSignalValues') or {}) if not all([custom_signal_operator, custom_signal_key, target_custom_signal_values]): logger.warning("Missing operator, key, or target values for custom signal condition.") @@ -471,71 +469,71 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, logger.warning("Custom signal value not found in context: %s", custom_signal_key) return False - if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target in actual) - if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN.value: return not self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target in actual) - if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target.strip() == actual.strip()) - if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, re.search) # For numeric operators only one target value is allowed. - if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) # For semantic operators only one target value is allowed. - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) @@ -589,25 +587,27 @@ def _compare_semantic_versions(self, target_value, actual_value, predicate_fn) - return self._compare_versions(str(actual_value), str(target_value), predicate_fn) - def _compare_versions(self, version1, version2, predicate_fn) -> bool: + def _compare_versions(self, sem_version_1, sem_version_2, predicate_fn) -> bool: """Compares two semantic version strings. Args: - version1: The first semantic version string. - version2: The second semantic version string. - predicate_fn: A function that takes an integer and returns a boolean. + sem_version_1: The first semantic version string. + sem_version_2: The second semantic version string. + predicate_fn: A function that takes an integer and returns a boolean. Returns: bool: The result of the predicate function. """ try: - v1_parts = [int(part) for part in version1.split('.')] - v2_parts = [int(part) for part in version2.split('.')] + v1_parts = [int(part) for part in sem_version_1.split('.')] + v2_parts = [int(part) for part in sem_version_2.split('.')] max_length = max(len(v1_parts), len(v2_parts)) v1_parts.extend([0] * (max_length - len(v1_parts))) v2_parts.extend([0] * (max_length - len(v2_parts))) for part1, part2 in zip(v1_parts, v2_parts): + if any((part1 < 0, part2 < 0)): + raise ValueError if part1 < part2: return predicate_fn(-1) if part1 > part2: @@ -674,7 +674,7 @@ def as_string(self) -> str: """Returns the value as a string.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_STRING - return self.value + return str(self.value) def as_boolean(self) -> bool: """Returns the value as a boolean.""" @@ -686,13 +686,19 @@ def as_int(self) -> float: """Returns the value as a number.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_INTEGER - return self.value + try: + return int(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_INTEGER def as_float(self) -> float: """Returns the value as a number.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER - return float(self.value) + try: + return float(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER def get_source(self) -> ValueSource: """Returns the source of the value.""" diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 914b99cb..2eabe15e 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -1,4 +1,4 @@ -# Copyright 2017 Google Inc. +# Copyright 2024 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import pytest import firebase_admin from firebase_admin.remote_config import ( + CustomSignalOperator, PercentConditionOperator, _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService, @@ -66,6 +67,17 @@ 'version': VERSION_INFO, } +SEMENTIC_VERSION_LESS_THAN_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.443', True] +SEMENTIC_VERSION_EQUAL_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value, ['12.1.3.444'], '12.1.3.444', True] +SEMANTIC_VERSION_GREATER_THAN_FALSE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.4'], '12.1.3.4', False] +SEMANTIC_VERSION_INVALID_FORMAT_STRING = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.abc', False] +SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.-2', False] + class TestEvaluate: @classmethod def setup_class(cls): @@ -272,7 +284,7 @@ def test_evaluate_default_when_no_param(self): ) server_config = server_template.evaluate() assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') - assert server_config.get_int('promo_discount') == default_config.get('promo_discount') + assert server_config.get_int('promo_discount') == int(default_config.get('promo_discount')) def test_evaluate_default_when_no_default_value(self): app = firebase_admin.get_app() @@ -319,8 +331,7 @@ def test_evaluate_default_when_defined(self): template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() - assert server_config.get_value('dog_type').as_string() == 'shiba' - assert server_config.get_value('dog_type').get_source() == 'default' + assert server_config.get_string('dog_type') == 'shiba' def test_evaluate_return_numeric_value(self): app = firebase_admin.get_app() @@ -334,7 +345,7 @@ def test_evaluate_return_numeric_value(self): template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() - assert server_config.get_int('dog_age') == default_config.get('dog_age') + assert server_config.get_int('dog_age') == int(default_config.get('dog_age')) def test_evaluate_return_boolean_value(self): app = firebase_admin.get_app() @@ -360,7 +371,7 @@ def test_evaluate_unknown_operator_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.UNKNOWN + 'percentOperator': PercentConditionOperator.UNKNOWN.value } }], } @@ -402,7 +413,7 @@ def test_evaluate_less_or_equal_to_max_to_true(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, 'seed': 'abcdef', 'microPercent': 100_000_000 } @@ -446,7 +457,7 @@ def test_evaluate_undefined_micropercent_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, # Leaves microPercent undefined } }], @@ -489,7 +500,7 @@ def test_evaluate_undefined_micropercentrange_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, # Leaves microPercent undefined } }], @@ -532,7 +543,7 @@ def test_evaluate_between_min_max_to_true(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 0, @@ -579,7 +590,7 @@ def test_evaluate_between_equal_bounds_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 50000000, @@ -626,7 +637,7 @@ def test_evaluate_less_or_equal_to_approx(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, 'seed': 'abcdef', 'microPercent': 10_000_000 # 10% } @@ -656,7 +667,7 @@ def test_evaluate_between_approx(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 40_000_000, @@ -689,7 +700,7 @@ def test_evaluate_between_interquartile_range_accuracy(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 25_000_000, @@ -708,7 +719,7 @@ def test_evaluate_between_interquartile_range_accuracy(self): truthy_assignments = self.evaluate_random_assignments(condition, 100000, app, default_config) - tolerance = 474 + tolerance = 490 assert truthy_assignments >= 50000 - tolerance assert truthy_assignments <= 50000 + tolerance @@ -750,9 +761,67 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d return eval_true_count + @pytest.mark.parametrize( + 'custom_signal_opearator, \ + target_custom_signal_value, actual_custom_signal_value, parameter_value', + [ + SEMENTIC_VERSION_LESS_THAN_TRUE, + SEMANTIC_VERSION_GREATER_THAN_FALSE, + SEMENTIC_VERSION_EQUAL_TRUE, + SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER, + SEMANTIC_VERSION_INVALID_FORMAT_STRING + ]) + def test_evaluate_custom_signal_semantic_version(self, + custom_signal_opearator, + target_custom_signal_value, + actual_custom_signal_value, + parameter_value): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'customSignal': { + 'customSignalOperator': custom_signal_opearator, + 'customSignalKey': 'sementic_version_key', + 'targetCustomSignalValues': target_custom_signal_value + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123', 'sementic_version_key': actual_custom_signal_value} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') == parameter_value + class MockAdapter(testutils.MockAdapter): - """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" + """A Mock HTTP Adapter that provides Firebase Remote Config responses with ETag in header.""" ETAG = 'etag' From 3fcf9028eda7084c2d2d47a6af5eece318055d9b Mon Sep 17 00:00:00 2001 From: Pijush Chakraborty Date: Tue, 21 Jan 2025 12:00:03 +0530 Subject: [PATCH 4/7] Adding methods for fetching JSON representation of server template and the value source of the config values (#850) * Updating ServerTemplate to accomodate to_json() method * Updating unit tests and docstrings * Adding re-entrant lock to make template cache updates/reads atomic --------- Co-authored-by: Pijush Chakraborty --- firebase_admin/remote_config.py | 69 ++++++++++++++++++++------- tests/test_remote_config.py | 83 +++++++++++++++++++++++---------- 2 files changed, 111 insertions(+), 41 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 119f41e3..4b5d4611 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -17,7 +17,9 @@ """ import asyncio +import json import logging +import threading from typing import Dict, Optional, Literal, Union, Any from enum import Enum import re @@ -63,13 +65,12 @@ class CustomSignalOperator(Enum): SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" UNKNOWN = "UNKNOWN" -class ServerTemplateData: +class _ServerTemplateData: """Parses, validates and encapsulates template data and metadata.""" - def __init__(self, etag, template_data): + def __init__(self, template_data): """Initializes a new ServerTemplateData instance. Args: - etag: The string to be used for initialize the ETag property. template_data: The data to be parsed for getting the parameters and conditions. Raises: @@ -96,8 +97,10 @@ def __init__(self, etag, template_data): self._version = template_data['version'] self._etag = '' - if etag is not None and isinstance(etag, str): - self._etag = etag + if 'etag' in template_data and isinstance(template_data['etag'], str): + self._etag = template_data['etag'] + + self._template_data_json = json.dumps(template_data) @property def parameters(self): @@ -115,6 +118,10 @@ def version(self): def conditions(self): return self._conditions + @property + def template_data_json(self): + return self._template_data_json + class ServerTemplate: """Represents a Server Template with implementations for loading and evaluting the template.""" @@ -132,6 +139,7 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N # fetched from RC servers via the load API, or via the set API. self._cache = None self._stringified_default_config: Dict[str, str] = {} + self._lock = threading.RLock() # RC stores all remote values as string, but it's more intuitive # to declare default values with specific types, so this converts @@ -142,7 +150,9 @@ def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = N async def load(self): """Fetches the server template and caches the data.""" - self._cache = await self._rc_service.get_server_template() + rc_server_template = await self._rc_service.get_server_template() + with self._lock: + self._cache = rc_server_template def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig': """Evaluates the cached server template to produce a ServerConfig. @@ -161,22 +171,40 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser Call load() before calling evaluate().""") context = context or {} config_values = {} + + with self._lock: + template_conditions = self._cache.conditions + template_parameters = self._cache.parameters + # Initializes config Value objects with default values. if self._stringified_default_config is not None: for key, value in self._stringified_default_config.items(): config_values[key] = _Value('default', value) - self._evaluator = _ConditionEvaluator(self._cache.conditions, - self._cache.parameters, context, + self._evaluator = _ConditionEvaluator(template_conditions, + template_parameters, context, config_values) return ServerConfig(config_values=self._evaluator.evaluate()) - def set(self, template: ServerTemplateData): + def set(self, template_data_json: str): """Updates the cache to store the given template is of type ServerTemplateData. Args: - template: An object of type ServerTemplateData to be cached. + template_data_json: A json string representing ServerTemplateData to be cached. """ - self._cache = template + template_data_map = json.loads(template_data_json) + template_data = _ServerTemplateData(template_data_map) + + with self._lock: + self._cache = template_data + + def to_json(self): + """Provides the server template in a JSON format to be used for initialization later.""" + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling toJSON().""") + with self._lock: + template_json = self._cache.template_data_json + return template_json class ServerConfig: @@ -185,17 +213,25 @@ def __init__(self, config_values): self._config_values = config_values # dictionary of param key to values def get_boolean(self, key): + """Returns the value as a boolean.""" return self._get_value(key).as_boolean() def get_string(self, key): + """Returns the value as a string.""" return self._get_value(key).as_string() def get_int(self, key): + """Returns the value as an integer.""" return self._get_value(key).as_int() def get_float(self, key): + """Returns the value as a float.""" return self._get_value(key).as_float() + def get_value_source(self, key): + """Returns the source of the value.""" + return self._get_value(key).get_source() + def _get_value(self, key): return self._config_values.get(key, _Value('static')) @@ -233,7 +269,8 @@ async def get_server_template(self): except requests.exceptions.RequestException as error: raise self._handle_remote_config_error(error) else: - return ServerTemplateData(headers.get('etag'), template_data) + template_data['etag'] = headers.get('etag') + return _ServerTemplateData(template_data) def _get_url(self): """Returns project prefix for url, in the format of /v1/projects/${projectId}""" @@ -633,22 +670,22 @@ async def get_server_template(app: App = None, default_config: Optional[Dict[str return template def init_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None, - template_data: Optional[ServerTemplateData] = None): + template_data_json: Optional[str] = None): """Initializes a new ServerTemplate instance. Args: app: App instance to be used. This is optional and the default app instance will be used if not present. default_config: The default config to be used in the evaluated config. - template_data: An optional template data to be set on initialization. + template_data_json: An optional template data JSON to be set on initialization. Returns: ServerTemplate: A new ServerTemplate instance initialized with an optional template and config. """ template = ServerTemplate(app=app, default_config=default_config) - if template_data is not None: - template.set(template_data) + if template_data_json is not None: + template.set(template_data_json) return template class _Value: diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 2eabe15e..8c6248e1 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -21,8 +21,7 @@ CustomSignalOperator, PercentConditionOperator, _REMOTE_CONFIG_ATTRIBUTE, - _RemoteConfigService, - ServerTemplateData) + _RemoteConfigService) from firebase_admin import remote_config, _utils from tests import testutils @@ -121,16 +120,17 @@ def test_evaluate_or_and_true_condition_true(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') + assert server_config.get_value_source('is_enabled') == 'remote' def test_evaluate_or_and_false_condition_false(self): app = firebase_admin.get_app() @@ -165,12 +165,12 @@ def test_evaluate_or_and_false_condition_false(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() @@ -196,12 +196,12 @@ def test_evaluate_non_or_condition(self): }, 'parameterGroups': '', 'version': '', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() @@ -262,12 +262,12 @@ def test_evaluate_return_conditional_values_honor_order(self): }, 'parameterGroups':'', 'version':'', - 'etag': '123' + 'etag': 'etag' } server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' @@ -280,7 +280,7 @@ def test_evaluate_default_when_no_param(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') @@ -296,7 +296,7 @@ def test_evaluate_default_when_no_default_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('default_value') == default_config.get('default_value') @@ -313,7 +313,7 @@ def test_evaluate_default_when_in_default(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('inapp_default') == default_config.get('inapp_default') @@ -328,7 +328,7 @@ def test_evaluate_default_when_defined(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'shiba' @@ -342,7 +342,7 @@ def test_evaluate_return_numeric_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_int('dog_age') == int(default_config.get('dog_age')) @@ -356,7 +356,7 @@ def test_evaluate_return_boolean_value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('dog_is_cute') @@ -398,7 +398,7 @@ def test_evaluate_unknown_operator_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -442,7 +442,7 @@ def test_evaluate_less_or_equal_to_max_to_true(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -485,7 +485,7 @@ def test_evaluate_undefined_micropercent_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -528,7 +528,7 @@ def test_evaluate_undefined_micropercentrange_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -575,7 +575,7 @@ def test_evaluate_between_min_max_to_true(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -622,7 +622,7 @@ def test_evaluate_between_equal_bounds_to_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -750,7 +750,7 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d server_template = remote_config.init_server_template( app=mock_app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) for _ in range(num_of_assignments): @@ -814,7 +814,7 @@ def test_evaluate_custom_signal_semantic_version(self, server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') == parameter_value @@ -917,7 +917,7 @@ def test_init_server_template(self): template = remote_config.init_server_template( app=app, default_config={'default_test': 'default_value'}, - template_data=ServerTemplateData('etag', template_data) + template_data_json=json.dumps(template_data) ) config = template.evaluate() @@ -949,3 +949,36 @@ async def test_get_server_template(self): config = template.evaluate() assert config.get_string('test_key') == 'test_value' + + @pytest.mark.asyncio + async def test_server_template_to_json(self): + app = firebase_admin.get_app() + rc_instance = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } + }, + 'conditions': [], + 'version': 'test' + }) + + expected_template_json = '{"parameters": {' \ + '"test_key": {' \ + '"defaultValue": {' \ + '"value": "test_value"}, ' \ + '"conditionalValues": {}}}, "conditions": [], ' \ + '"version": "test", "etag": "etag"}' + + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + template = await remote_config.get_server_template(app=app) + + template_json = template.to_json() + assert template_json == expected_template_json From 6e37b99e3320aeb72d9d7be5b1ea99d9c5183c16 Mon Sep 17 00:00:00 2001 From: Pijush Chakraborty Date: Wed, 12 Feb 2025 20:08:05 +0530 Subject: [PATCH 5/7] Integrating minor fixes with SSRC branch * Fixing percentage lowebound issues * Updating logs * Fixing lint issues --------- Co-authored-by: Pijush Chakraborty --- firebase_admin/remote_config.py | 63 +++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 4b5d4611..9f878aec 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -431,7 +431,7 @@ def evaluate_percent_condition(self, percent_condition, True if the condition is met, False otherwise. """ if not context.get('randomization_id'): - logger.warning("Missing randomization ID for percent condition.") + logger.warning("Missing randomization_id in context for evaluating percent condition.") return False seed = percent_condition.get('seed') @@ -442,8 +442,8 @@ def evaluate_percent_condition(self, percent_condition, logger.warning("Missing percent operator for percent condition.") return False if micro_percent_range: - norm_percent_upper_bound = micro_percent_range.get('microPercentUpperBound') - norm_percent_lower_bound = micro_percent_range.get('microPercentLowerBound') + norm_percent_upper_bound = micro_percent_range.get('microPercentUpperBound') or 0 + norm_percent_lower_bound = micro_percent_range.get('microPercentLowerBound') or 0 else: norm_percent_upper_bound = 0 norm_percent_lower_bound = 0 @@ -503,7 +503,7 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, actual_custom_signal_value = context.get(custom_signal_key) or {} if not actual_custom_signal_value: - logger.warning("Custom signal value not found in context: %s", custom_signal_key) + logger.debug("Custom signal value not found in context: %s", custom_signal_key) return False if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS.value: @@ -525,53 +525,65 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, # For numeric operators only one target value is allowed. if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL.value: - return self._compare_numbers(target_custom_signal_values[0], + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) # For semantic operators only one target value is allowed. if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL.value: - return self._compare_semantic_versions(target_custom_signal_values[0], + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) logger.warning("Unknown custom signal operator: %s", custom_signal_operator) @@ -597,22 +609,25 @@ def _compare_strings(self, target_values, actual_value, predicate_fn) -> bool: return True return False - def _compare_numbers(self, target_value, actual_value, predicate_fn) -> bool: + def _compare_numbers(self, custom_signal_key, target_value, actual_value, predicate_fn) -> bool: try: target = float(target_value) actual = float(actual_value) result = -1 if actual < target else 1 if actual > target else 0 return predicate_fn(result) except ValueError: - logger.warning("Invalid numeric value for comparison.") + logger.warning("Invalid numeric value for comparison for custom signal key %s.", + custom_signal_key) return False - def _compare_semantic_versions(self, target_value, actual_value, predicate_fn) -> bool: + def _compare_semantic_versions(self, custom_signal_key, + target_value, actual_value, predicate_fn) -> bool: """Compares the actual semantic version value of a signal against a target value. Calls the predicate function with -1, 0, 1 if actual is less than, equal to, or greater than target. Args: + custom_signal_key: The custom singal for which the evaluation is being performed. target_values: A list of target string values. actual_value: The actual value to compare, which can be a string or number. predicate_fn: A function that takes an integer (-1, 0, or 1) and returns a boolean. @@ -621,13 +636,15 @@ def _compare_semantic_versions(self, target_value, actual_value, predicate_fn) - bool: True if the predicate function returns True for the result of the comparison, False otherwise. """ - return self._compare_versions(str(actual_value), + return self._compare_versions(custom_signal_key, str(actual_value), str(target_value), predicate_fn) - def _compare_versions(self, sem_version_1, sem_version_2, predicate_fn) -> bool: + def _compare_versions(self, custom_signal_key, + sem_version_1, sem_version_2, predicate_fn) -> bool: """Compares two semantic version strings. Args: + custom_signal_key: The custom singal for which the evaluation is being performed. sem_version_1: The first semantic version string. sem_version_2: The second semantic version string. predicate_fn: A function that takes an integer and returns a boolean. @@ -651,7 +668,9 @@ def _compare_versions(self, sem_version_1, sem_version_2, predicate_fn) -> bool: return predicate_fn(1) return predicate_fn(0) except ValueError: - logger.warning("Invalid semantic version format for comparison.") + logger.warning( + "Invalid semantic version format for comparison for custom signal key %s.", + custom_signal_key) return False async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None): From 33eebb7a126646380f5570b345f137c7c5696667 Mon Sep 17 00:00:00 2001 From: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Date: Thu, 20 Feb 2025 15:08:38 +0530 Subject: [PATCH 6/7] Update remote_config.py Fixed typos in comments and added additional description --- firebase_admin/remote_config.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 9f878aec..943141cc 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -124,7 +124,7 @@ def template_data_json(self): class ServerTemplate: - """Represents a Server Template with implementations for loading and evaluting the template.""" + """Represents a Server Template with implementations for loading and evaluating the template.""" def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None): """Initializes a ServerTemplate instance. @@ -293,7 +293,7 @@ def __init__(self, conditions, parameters, context, config_values): self._config_values = config_values def evaluate(self): - """Internal function Evaluates the cached server template to produce + """Internal function that evaluates the cached server template to produce a ServerConfig""" evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) @@ -338,7 +338,7 @@ def evaluate_conditions(self, conditions, context)-> Dict[str, bool]: context: An EvaluationContext object. Returns: - A dictionary mapping condition names to boolean evaluation results. + A dictionary that maps condition names to boolean evaluation results. """ evaluated_conditions = {} for condition in conditions: @@ -410,7 +410,7 @@ def evaluate_and_condition(self, and_condition, nesting_level: The current recursion depth. Returns: - True if all of the subconditions are true, False otherwise. + True if all of the subconditions are met; False otherwise. """ sub_conditions = and_condition.get('conditions') or [] for sub_condition in sub_conditions: @@ -627,7 +627,7 @@ def _compare_semantic_versions(self, custom_signal_key, or greater than target. Args: - custom_signal_key: The custom singal for which the evaluation is being performed. + custom_signal_key: The custom signal for which the evaluation is being performed. target_values: A list of target string values. actual_value: The actual value to compare, which can be a string or number. predicate_fn: A function that takes an integer (-1, 0, or 1) and returns a boolean. @@ -721,6 +721,9 @@ def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): Args: source: The source of the value (e.g., 'default', 'remote', 'static'). + "static" indicates the value was defined by a static constant. + "default" indicates the value was defined by default config. + "remote" indicates the value was defined by config produced by evaluating a template. value: The string value. """ self.source = source From aebf373584c0974b46a6145c1258df888b4f656d Mon Sep 17 00:00:00 2001 From: Pijush Chakraborty Date: Wed, 5 Mar 2025 22:04:02 +0530 Subject: [PATCH 7/7] Trigger Staging