From 72e5382aa381571351969f4638f6efdac4a08032 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Tue, 29 Oct 2024 00:44:53 +0530 Subject: [PATCH 01/15] Added implemenation of evaluate function --- firebase_admin/remote_config.py | 597 ++++++++++++++++++++++++++++++++ requirements.txt | 3 +- 2 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 firebase_admin/remote_config.py diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py new file mode 100644 index 000000000..576d6e36f --- /dev/null +++ b/firebase_admin/remote_config.py @@ -0,0 +1,597 @@ +# 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 logging +from enum import Enum +from typing import Dict, Optional, Literal, List, Callable, Any, Union +import re +import farmhash +from firebase_admin import _http_client + +# Set up logging (you can customize the level and output) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type + +MAX_CONDITION_RECURSION_DEPTH = 10 + +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" + +class Condition: + """ + Base class for conditions. + """ + def __init__(self): + # This is just a base class, so it doesn't need any attributes + pass + + +class OrCondition(Condition): + """ + Represents an OR condition. + """ + def __init__(self, conditions: List[Condition]): + super().__init__() + self.conditions = conditions + + +class AndCondition(Condition): + """ + Represents an AND condition. + """ + def __init__(self, conditions: List[Condition]): + super().__init__() + self.conditions = conditions + + +class PercentCondition(Condition): + """ + Represents a percent condition. + """ + def __init__(self, seed: str, percent_operator: PercentConditionOperator, micro_percent: int, micro_percent_range: Optional[Dict[str, int]] = None): + super().__init__() + self.seed = seed + self.percent_operator = percent_operator + self.micro_percent = micro_percent + self.micro_percent_range = micro_percent_range + + +class CustomSignalCondition(Condition): + """ + Represents a custom signal condition. + """ + def __init__(self, custom_signal_operator: CustomSignalOperator, custom_signal_key: str, target_custom_signal_values: List[Union[str, int, float]]): + super().__init__() + self.custom_signal_operator = custom_signal_operator + self.custom_signal_key = custom_signal_key + self.target_custom_signal_values = target_custom_signal_values + + +class OneOfCondition(Condition): + """ + Represents a condition that can be one of several types. + """ + def __init__(self, or_condition: Optional[OrCondition] = None, and_condition: Optional[AndCondition] = None, true_condition: Optional[bool] = None, false_condition: Optional[bool] = None, percent_condition: Optional[PercentCondition] = None, custom_signal_condition: Optional[CustomSignalCondition] = None): + super().__init__() + self.or_condition = or_condition + self.and_condition = and_condition + self.true_condition = true_condition + self.false_condition = false_condition + self.percent_condition = percent_condition + self.custom_signal_condition = custom_signal_condition + + +class NamedCondition: + """ + Represents a named condition. + """ + def __init__(self, name: str, condition: OneOfCondition): + self.name = name + self.condition = condition + + +class EvaluationContext: + """ + Represents the context for evaluating conditions. + """ + def __init__(self, **kwargs): + # This allows you to pass any key-value pairs to the context + # For example: EvaluationContext(user_country="US", user_type="paid") + self.__dict__.update(kwargs) + + def __getattr__(self, item): + # This handles the case where a key is not found in the context + return None + +class ConditionEvaluator: + """ + Encapsulates condition evaluation logic to simplify organization and + facilitate testing. + """ + + def evaluate_conditions(self, named_conditions: List['NamedCondition'], context: 'EvaluationContext') -> Dict[str, bool]: + """ + Evaluates a list of named conditions and returns a dictionary of results. + + Args: + named_conditions: A list of NamedCondition objects. + context: An EvaluationContext object. + + Returns: + A dictionary mapping condition names to boolean evaluation results. + """ + evaluated_conditions = {} + for named_condition in named_conditions: + evaluated_conditions[named_condition.name] = self.evaluate_condition( + named_condition.condition, context) + return evaluated_conditions + + def evaluate_condition(self, condition: 'OneOfCondition', context: 'EvaluationContext', 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.or_condition: + return self.evaluate_or_condition(condition.or_condition, context, nesting_level + 1) + if condition.and_condition: + return self.evaluate_and_condition(condition.and_condition, context, nesting_level + 1) + if condition.true_condition: + return True + if condition.false_condition: + return False + if condition.percent_condition: + return self.evaluate_percent_condition(condition.percent_condition, context) + if condition.custom_signal_condition: + return self.evaluate_custom_signal_condition(condition.custom_signal_condition, context) + + logger.warning("Unknown condition type encountered.") + return False + + def evaluate_or_condition(self, or_condition: 'OrCondition', context: 'EvaluationContext', nesting_level: int) -> 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.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: 'AndCondition', context: 'EvaluationContext', nesting_level: int) -> 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.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: 'PercentCondition', context: 'EvaluationContext') -> 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.randomization_id: + logger.warning("Missing randomization ID for percent condition.") + return False + + seed = percent_condition.seed + percent_operator = percent_condition.percent_operator + micro_percent = percent_condition.micro_percent or 0 + micro_percent_range = percent_condition.micro_percent_range + + if not percent_operator: + logger.warning("Missing percent operator for percent condition.") + return False + + normalized_micro_percent_upper_bound = micro_percent_range.micro_percent_upper_bound if micro_percent_range else 0 + normalized_micro_percent_lower_bound = micro_percent_range.micro_percent_lower_bound if micro_percent_range else 0 + + seed_prefix = f"{seed}." if seed else "" + string_to_hash = f"{seed_prefix}{context.randomization_id}" + + hash64 = ConditionEvaluator.hash_seeded_randomization_id(string_to_hash) + + instance_micro_percentile = hash64 % (100 * 1_000_000) + + if percent_operator == "LESS_OR_EQUAL": + return instance_micro_percentile <= micro_percent + elif percent_operator == "GREATER_THAN": + return instance_micro_percentile > micro_percent + elif percent_operator == "BETWEEN": + return normalized_micro_percent_lower_bound < instance_micro_percentile <= normalized_micro_percent_upper_bound + else: + logger.warning("Unknown percent operator: %s", percent_operator) + return False + + @staticmethod + def hash_seeded_randomization_id(seeded_randomization_id: str) -> int: + """ + Hashes a seeded randomization ID. + + Args: + seeded_randomization_id: The seeded randomization ID to hash. + + Returns: + The hashed value. + """ + hash64 = farmhash.fingerprint64(seeded_randomization_id) + return abs(hash64) + + def evaluate_custom_signal_condition(self, custom_signal_condition: 'CustomSignalCondition', context: 'EvaluationContext') -> 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.custom_signal_operator + custom_signal_key = custom_signal_condition.custom_signal_key + target_custom_signal_values = custom_signal_condition.target_custom_signal_values + + 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 = getattr(context, custom_signal_key, None) + + if actual_custom_signal_value is None: + logger.warning("Custom signal value not found in context: %s", custom_signal_key) + return False + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: + return compare_strings(lambda target, actual: target in actual) + elif custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + return not compare_strings(lambda target, actual: target in actual) + elif custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + return compare_strings(lambda target, actual: target.strip() == actual.strip()) + elif custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + return compare_strings(lambda target, actual: re.search(target, actual) is not None) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + return compare_numbers(lambda r: r < 0) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + return compare_numbers(lambda r: r <= 0) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + return compare_numbers(lambda r: r == 0) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + return compare_numbers(lambda r: r != 0) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + return compare_numbers(lambda r: r > 0) + elif custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + return compare_numbers(lambda r: r >= 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + return compare_semantic_versions(lambda r: r < 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + return compare_semantic_versions(lambda r: r <= 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + return compare_semantic_versions(lambda r: r == 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + return compare_semantic_versions(lambda r: r != 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + return compare_semantic_versions(lambda r: r > 0) + elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + return compare_semantic_versions(lambda r: r >= 0) + + logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + return False + + def compare_strings(predicate_fn: Callable[[str, str], bool]) -> bool: + return any(predicate_fn(target, str(actual_custom_signal_value)) for target in target_custom_signal_values) + + def compare_numbers(predicate_fn: Callable[[int], bool]) -> bool: + try: + target = float(target_custom_signal_values[0]) + actual = float(actual_custom_signal_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(predicate_fn: Callable[[int], bool]) -> bool: + return compare_versions(str(actual_custom_signal_value), str(target_custom_signal_values[0]), predicate_fn) + + def compare_versions(version1: str, version2: str, predicate_fn: Callable[[int], bool]) -> 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: + 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) + elif part1 > part2: + return predicate_fn(1) + return predicate_fn(0) + + except ValueError: + logger.warning("Invalid semantic version format for comparison.") + return False + + +class RemoteConfig: + """ + Represents a Server + Side Remote Config Class. + """ + + def __init__(self, app=None): + timeout = app.options.get('httpTimeout', + _http_client.DEFAULT_TIMEOUT_SECONDS) + self._credential = app.credential.get_credential() + self._api_client = _http_client.RemoteConfigApiClient( + credential=self._credential, timeout=timeout) + + async def get_server_template(self, default_config: Optional[Dict[str, str]] = None): + template = self.init_server_template(default_config) + await template.load() + return template + + def init_server_template(self, default_config: Optional[Dict[str, str]] = None): + template = ServerTemplate(self._api_client, + default_config=default_config) + return template + +class ServerTemplateData: + """Represents a Server Template Data class.""" + + def __init__(self, template: Dict[str, Any]): + self.conditions = template.get('conditions', []) + self.parameters = template.get('parameters', {}) + # ... (Add any other necessary attributes from the template data) ... + + +class ServerTemplate: + """Represents a Server Template with implementations for loading and evaluating the template.""" + + def __init__(self, client, default_config: Optional[Dict[str, str]] = None): + """ + Initializes a ServerTemplate instance. + + Args: + client: The API client used to fetch the server template. + default_config: A dictionary of default configuration values. + """ + self._client = client + self._condition_evaluator = ConditionEvaluator() + self._cache = None + self._stringified_default_config = {key: str(value) for key, value in default_config.items()} if default_config else {} + + async def load(self): + """Fetches and caches the server template from the Remote Config API.""" + self._cache = await self._client.get_server_template() + + def set(self, template): + """ + Sets the server template from a string or ServerTemplateData object. + + Args: + template: The template to set, either as a JSON string or a ServerTemplateData object. + """ + if isinstance(template, str): + try: + import json + parsed_template = json.loads(template) + self._cache = ServerTemplateData(parsed_template) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse the JSON string: {template}. {e}") + elif isinstance(template, ServerTemplateData): + self._cache = template + else: + raise TypeError("template must be a string or ServerTemplateData object") + + 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. + """ + if not self._cache: + raise ValueError("No Remote Config Server template in cache. Call load() before calling evaluate().") + + context = context or {} + evaluated_conditions = self._condition_evaluator.evaluate_conditions( + self._cache.conditions, EvaluationContext(**context) + ) + + config_values = {} + + for key, value in self._stringified_default_config.items(): + config_values[key] = Value('default', value) + + for key, parameter in self._cache.parameters.items(): + conditional_values = parameter.get('conditionalValues', {}) + default_value = parameter.get('defaultValue') + + parameter_value_wrapper = None + 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: + config_values[key] = Value('remote', parameter_value_wrapper.get('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 + + config_values[key] = Value('remote', default_value.get('value')) + + return ServerConfig(config_values) + + +class ServerConfig: + """Represents a Remote Config Server Side Config.""" + + def __init__(self, config_values): + self._config_values = config_values + + def get_boolean(self, key): + return self._config_values[key].as_boolean() + + def get_string(self, key): + return self._config_values[key].as_string() + + def get_int(self, key): + return int(self._config_values[key].as_number()) + + def get_value(self, key): + return self._config_values[key] + +class Value: + """ + Represents a value fetched from Remote Config. + """ + DEFAULT_VALUE_FOR_BOOLEAN = False + DEFAULT_VALUE_FOR_STRING = '' + DEFAULT_VALUE_FOR_NUMBER = 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.""" + 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 self.value.lower() in self.BOOLEAN_TRUTHY_VALUES + + def as_number(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_NUMBER + try: + return float(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_NUMBER + + def get_source(self) -> ValueSource: + """Returns the source of the value.""" + return self.source diff --git a/requirements.txt b/requirements.txt index acf09438b..c726b758a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != ' google-api-python-client >= 1.7.8 google-cloud-firestore >= 2.9.1; platform.python_implementation != 'PyPy' google-cloud-storage >= 1.37.1 -pyjwt[crypto] >= 2.5.0 \ No newline at end of file +pyjwt[crypto] >= 2.5.0 +pyfarmhash >= 0.4.0 \ No newline at end of file From 94afdbee762261a6fc33b7362339141adda2fe82 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Wed, 30 Oct 2024 18:32:36 +0530 Subject: [PATCH 02/15] Improvement --- firebase_admin/remote_config.py | 619 ++++++++++++++++---------------- 1 file changed, 312 insertions(+), 307 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 576d6e36f..4411db4de 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -11,40 +11,30 @@ # 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. + +"""Firebase Remote Config Module. +This module has required APIs for the clients to use Firebase Remote Config with python. """ +import json import logging +from typing import Dict, Optional, Literal, Callable, Union from enum import Enum -from typing import Dict, Optional, Literal, List, Callable, Any, Union import re import farmhash -from firebase_admin import _http_client +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__) -ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type - +_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig' MAX_CONDITION_RECURSION_DEPTH = 10 - -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" - +ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type class CustomSignalOperator(Enum): - """ - Enum representing the available operators for custom signal conditions. + """Enum representing the available operators for custom signal conditions. """ STRING_CONTAINS = "STRING_CONTAINS" STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN" @@ -63,101 +53,207 @@ class CustomSignalOperator(Enum): SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN" SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" -class Condition: - """ - Base class for conditions. - """ - def __init__(self): - # This is just a base class, so it doesn't need any attributes - pass +class ServerTemplateData: + """Represents a Server Template Data class.""" + 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. + """ + self._parameters = template_data['parameters'] + self._conditions = template_data['conditions'] + self._version = template_data['version'] + self._parameter_groups = template_data['parameterGroups'] + self._etag = etag -class OrCondition(Condition): - """ - Represents an OR condition. - """ - def __init__(self, conditions: List[Condition]): - super().__init__() - self.conditions = conditions + @property + def parameters(self): + return self._parameters + @property + def etag(self): + return self._etag -class AndCondition(Condition): - """ - Represents an AND condition. - """ - def __init__(self, conditions: List[Condition]): - super().__init__() - self.conditions = conditions + @property + def version(self): + return self._version + @property + def conditions(self): + return self._conditions -class PercentCondition(Condition): - """ - Represents a percent condition. - """ - def __init__(self, seed: str, percent_operator: PercentConditionOperator, micro_percent: int, micro_percent_range: Optional[Dict[str, int]] = None): - super().__init__() - self.seed = seed - self.percent_operator = percent_operator - self.micro_percent = micro_percent - self.micro_percent_range = micro_percent_range + @property + def parameter_groups(self): + return self._parameter_groups -class CustomSignalCondition(Condition): - """ - Represents a custom signal condition. - """ - def __init__(self, custom_signal_operator: CustomSignalOperator, custom_signal_key: str, target_custom_signal_values: List[Union[str, int, float]]): - super().__init__() - self.custom_signal_operator = custom_signal_operator - self.custom_signal_key = custom_signal_key - self.target_custom_signal_values = target_custom_signal_values +class ServerTemplate: + """Represents a Server Template with implementations for loading and evaluting the tempalte.""" + 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 + if default_config is not None: + self._stringified_default_config = json.dumps(default_config) + else: + self._stringified_default_config = None + async def load(self): + """Fetches the server template and caches the data.""" + self._cache = await self._rc_service.getServerTemplate() -class OneOfCondition(Condition): - """ - Represents a condition that can be one of several types. - """ - def __init__(self, or_condition: Optional[OrCondition] = None, and_condition: Optional[AndCondition] = None, true_condition: Optional[bool] = None, false_condition: Optional[bool] = None, percent_condition: Optional[PercentCondition] = None, custom_signal_condition: Optional[CustomSignalCondition] = None): - super().__init__() - self.or_condition = or_condition - self.and_condition = and_condition - self.true_condition = true_condition - self.false_condition = false_condition - self.percent_condition = percent_condition - self.custom_signal_condition = custom_signal_condition + 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. -class NamedCondition: - """ - Represents a named condition. - """ - def __init__(self, name: str, condition: OneOfCondition): - self.name = name - self.condition = condition + Returns: + A ServerConfig object. + Raises: + ValueError: If the input arguments are invalid. + """ + # Logic to process the cached template into a ServerConfig here. + # TODO: Add Condition evaluator. + 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. + for key, value in self._stringified_default_config.items(): + config_values[key] = _Value('default', value) -class EvaluationContext: - """ - Represents the context for evaluating conditions. - """ - def __init__(self, **kwargs): - # This allows you to pass any key-value pairs to the context - # For example: EvaluationContext(user_country="US", user_type="paid") - self.__dict__.update(kwargs) + self._evaluator = _ConditionEvaluator(self._cache.conditions, context, + config_values, self._cache.parameters) + return ServerConfig(config_values=self._evaluator.evaluate()) - def __getattr__(self, item): - # This handles the case where a key is not found in the context - return None + def set(self, template): + """Updates the cache to store the given template is of type ServerTemplateData. -class ConditionEvaluator: - """ - Encapsulates condition evaluation logic to simplify organization and - facilitate testing. + Args: + template: An object of type ServerTemplateData to be cached. + """ + if isinstance(template, ServerTemplateData): + 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. - def evaluate_conditions(self, named_conditions: List['NamedCondition'], context: 'EvaluationContext') -> Dict[str, bool]: + Args: + app: App instance to be used for fetching app specific details required + for initializing the http client. """ - Evaluates a list of named conditions and returns a dictionary of results. + 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) + + + 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.""" + url_prefix = self._get_url_prefix() + headers, response_json = self._client.headers_and_body('get', + url=url_prefix+'/namespaces/ \ + firebase-server/serverRemoteConfig') + return ServerTemplateData(headers.get('ETag'), response_json) + + def _get_url_prefix(self): + # Returns project prefix for url, in the format of + # /v1/projects/${projectId} + return "/v1/projects/{0}".format(self._project_id) + + +class _ConditionEvaluator: + """Internal class that facilitates sending requests to the Firebase Remote + Config backend API.""" + def __init__(self, context, conditions, config_values, parameters): + self._context = context + self._conditions = conditions + self._parameters = parameters + self._config_values = config_values + + def evaluate(self): + """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. + for key, parameter in self._parameters.items(): + conditional_values = parameter.conditional_values or {} + default_value = parameter.default_value or {} + 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. + 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.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, named_conditions, context)-> Dict[str, bool]: + """Evaluates a list of named conditions and returns a dictionary of results. Args: named_conditions: A list of NamedCondition objects. @@ -169,12 +265,13 @@ def evaluate_conditions(self, named_conditions: List['NamedCondition'], context: evaluated_conditions = {} for named_condition in named_conditions: evaluated_conditions[named_condition.name] = self.evaluate_condition( - named_condition.condition, context) + named_condition.condition, context + ) return evaluated_conditions - def evaluate_condition(self, condition: 'OneOfCondition', context: 'EvaluationContext', nesting_level: int = 0) -> bool: - """ - Recursively evaluates a condition. + def evaluate_condition(self, condition, context, + nesting_level: int = 0) -> bool: + """Recursively evaluates a condition. Args: condition: The condition to evaluate. @@ -187,7 +284,6 @@ def evaluate_condition(self, condition: 'OneOfCondition', context: 'EvaluationCo if nesting_level >= MAX_CONDITION_RECURSION_DEPTH: logger.warning("Maximum condition recursion depth exceeded.") return False - if condition.or_condition: return self.evaluate_or_condition(condition.or_condition, context, nesting_level + 1) if condition.and_condition: @@ -200,13 +296,13 @@ def evaluate_condition(self, condition: 'OneOfCondition', context: 'EvaluationCo return self.evaluate_percent_condition(condition.percent_condition, context) if condition.custom_signal_condition: return self.evaluate_custom_signal_condition(condition.custom_signal_condition, context) - logger.warning("Unknown condition type encountered.") return False - def evaluate_or_condition(self, or_condition: 'OrCondition', context: 'EvaluationContext', nesting_level: int) -> bool: - """ - Evaluates an OR condition. + 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. @@ -223,9 +319,10 @@ def evaluate_or_condition(self, or_condition: 'OrCondition', context: 'Evaluatio return True return False - def evaluate_and_condition(self, and_condition: 'AndCondition', context: 'EvaluationContext', nesting_level: int) -> bool: - """ - Evaluates an AND condition. + 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. @@ -242,9 +339,9 @@ def evaluate_and_condition(self, and_condition: 'AndCondition', context: 'Evalua return False return True - def evaluate_percent_condition(self, percent_condition: 'PercentCondition', context: 'EvaluationContext') -> bool: - """ - Evaluates a percent condition. + def evaluate_percent_condition(self, percent_condition, + context) -> bool: + """Evaluates a percent condition. Args: percent_condition: The percent condition to evaluate. @@ -258,38 +355,36 @@ def evaluate_percent_condition(self, percent_condition: 'PercentCondition', cont return False seed = percent_condition.seed - percent_operator = percent_condition.percent_operator + percent_operator = percent_condition.percent_operator micro_percent = percent_condition.micro_percent or 0 micro_percent_range = percent_condition.micro_percent_range if not percent_operator: logger.warning("Missing percent operator for percent condition.") return False - - normalized_micro_percent_upper_bound = micro_percent_range.micro_percent_upper_bound if micro_percent_range else 0 - normalized_micro_percent_lower_bound = micro_percent_range.micro_percent_lower_bound if micro_percent_range else 0 - + if micro_percent_range: + norm_percent_upper_bound = micro_percent_range.micro_percent_upper_bound + norm_percent_lower_bound = micro_percent_range.micro_percent_lower_bound + else: + norm_percent_upper_bound = 0 + norm_percent_lower_bound = 0 seed_prefix = f"{seed}." if seed else "" string_to_hash = f"{seed_prefix}{context.randomization_id}" - hash64 = ConditionEvaluator.hash_seeded_randomization_id(string_to_hash) + hash64 = self.hash_seeded_randomization_id(string_to_hash) instance_micro_percentile = hash64 % (100 * 1_000_000) if percent_operator == "LESS_OR_EQUAL": return instance_micro_percentile <= micro_percent - elif percent_operator == "GREATER_THAN": + if percent_operator == "GREATER_THAN": return instance_micro_percentile > micro_percent - elif percent_operator == "BETWEEN": - return normalized_micro_percent_lower_bound < instance_micro_percentile <= normalized_micro_percent_upper_bound - else: - logger.warning("Unknown percent operator: %s", percent_operator) - return False - - @staticmethod - def hash_seeded_randomization_id(seeded_randomization_id: str) -> int: - """ - Hashes a seeded randomization ID. + if percent_operator == "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. @@ -297,12 +392,11 @@ def hash_seeded_randomization_id(seeded_randomization_id: str) -> int: Returns: The hashed value. """ - hash64 = farmhash.fingerprint64(seeded_randomization_id) + hash64 = farmhash.hash64withseed(seeded_randomization_id) return abs(hash64) - - def evaluate_custom_signal_condition(self, custom_signal_condition: 'CustomSignalCondition', context: 'EvaluationContext') -> bool: - """ - Evaluates a custom signal condition. + 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. @@ -321,50 +415,59 @@ def evaluate_custom_signal_condition(self, custom_signal_condition: 'CustomSigna if not target_custom_signal_values: return False - actual_custom_signal_value = getattr(context, custom_signal_key, None) - if actual_custom_signal_value is None: logger.warning("Custom signal value not found in context: %s", custom_signal_key) return False if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: return compare_strings(lambda target, actual: target in actual) - elif custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: return not compare_strings(lambda target, actual: target in actual) - elif custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: return compare_strings(lambda target, actual: target.strip() == actual.strip()) - elif custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: return compare_strings(lambda target, actual: re.search(target, actual) is not None) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: return compare_numbers(lambda r: r < 0) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: return compare_numbers(lambda r: r <= 0) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: return compare_numbers(lambda r: r == 0) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: return compare_numbers(lambda r: r != 0) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: return compare_numbers(lambda r: r > 0) - elif custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: return compare_numbers(lambda r: r >= 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: return compare_semantic_versions(lambda r: r < 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: return compare_semantic_versions(lambda r: r <= 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: return compare_semantic_versions(lambda r: r == 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: return compare_semantic_versions(lambda r: r != 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: return compare_semantic_versions(lambda r: r > 0) - elif custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: return compare_semantic_versions(lambda r: r >= 0) - logger.warning("Unknown custom signal operator: %s", custom_signal_operator) - return False - def compare_strings(predicate_fn: Callable[[str, str], bool]) -> bool: - return any(predicate_fn(target, str(actual_custom_signal_value)) for target in target_custom_signal_values) + """Compares the actual string value of a signal against a list of target values. + + Args: + 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_custom_signal_values: + if predicate_fn(target, str(actual_custom_signal_value)): + return True + return False def compare_numbers(predicate_fn: Callable[[int], bool]) -> bool: try: @@ -377,11 +480,22 @@ def compare_numbers(predicate_fn: Callable[[int], bool]) -> bool: return False def compare_semantic_versions(predicate_fn: Callable[[int], bool]) -> bool: - return compare_versions(str(actual_custom_signal_value), str(target_custom_signal_values[0]), predicate_fn) - - def compare_versions(version1: str, version2: str, predicate_fn: Callable[[int], bool]) -> 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: + 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. """ - Compares two semantic version strings. + return compare_versions(str(actual_custom_signal_value), + str(target_custom_signal_values[0]), predicate_fn) + def compare_versions(version1: str, version2: str, + predicate_fn: Callable[[int], bool]) -> bool: + """Compares two semantic version strings. Args: version1: The first semantic version string. @@ -389,7 +503,7 @@ def compare_versions(version1: str, version2: str, predicate_fn: Callable[[int], predicate_fn: A function that takes an integer and returns a boolean. Returns: - The result of the predicate function. + bool: The result of the predicate function. """ try: v1_parts = [int(part) for part in version1.split('.')] @@ -401,161 +515,52 @@ def compare_versions(version1: str, version2: str, predicate_fn: Callable[[int], for part1, part2 in zip(v1_parts, v2_parts): if part1 < part2: return predicate_fn(-1) - elif part1 > part2: + if part1 > part2: return predicate_fn(1) - return predicate_fn(0) - + return predicate_fn(0) except ValueError: logger.warning("Invalid semantic version format for comparison.") return False + logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + return False -class RemoteConfig: - """ - Represents a Server - Side Remote Config Class. - """ - - def __init__(self, app=None): - timeout = app.options.get('httpTimeout', - _http_client.DEFAULT_TIMEOUT_SECONDS) - self._credential = app.credential.get_credential() - self._api_client = _http_client.RemoteConfigApiClient( - credential=self._credential, timeout=timeout) - - async def get_server_template(self, default_config: Optional[Dict[str, str]] = None): - template = self.init_server_template(default_config) - await template.load() - return template - - def init_server_template(self, default_config: Optional[Dict[str, str]] = None): - template = ServerTemplate(self._api_client, - default_config=default_config) - return template - -class ServerTemplateData: - """Represents a Server Template Data class.""" - - def __init__(self, template: Dict[str, Any]): - self.conditions = template.get('conditions', []) - self.parameters = template.get('parameters', {}) - # ... (Add any other necessary attributes from the template data) ... - - -class ServerTemplate: - """Represents a Server Template with implementations for loading and evaluating the template.""" - - def __init__(self, client, default_config: Optional[Dict[str, str]] = None): - """ - Initializes a ServerTemplate instance. - - Args: - client: The API client used to fetch the server template. - default_config: A dictionary of default configuration values. - """ - self._client = client - self._condition_evaluator = ConditionEvaluator() - self._cache = None - self._stringified_default_config = {key: str(value) for key, value in default_config.items()} if default_config else {} - - async def load(self): - """Fetches and caches the server template from the Remote Config API.""" - self._cache = await self._client.get_server_template() - - def set(self, template): - """ - Sets the server template from a string or ServerTemplateData object. - - Args: - template: The template to set, either as a JSON string or a ServerTemplateData object. - """ - if isinstance(template, str): - try: - import json - parsed_template = json.loads(template) - self._cache = ServerTemplateData(parsed_template) - except json.JSONDecodeError as e: - raise ValueError(f"Failed to parse the JSON string: {template}. {e}") - elif isinstance(template, ServerTemplateData): - self._cache = template - else: - raise TypeError("template must be a string or ServerTemplateData object") - - 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. - """ - if not self._cache: - raise ValueError("No Remote Config Server template in cache. Call load() before calling evaluate().") - - context = context or {} - evaluated_conditions = self._condition_evaluator.evaluate_conditions( - self._cache.conditions, EvaluationContext(**context) - ) - - config_values = {} - - for key, value in self._stringified_default_config.items(): - config_values[key] = Value('default', value) - - for key, parameter in self._cache.parameters.items(): - conditional_values = parameter.get('conditionalValues', {}) - default_value = parameter.get('defaultValue') - - parameter_value_wrapper = None - 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: - config_values[key] = Value('remote', parameter_value_wrapper.get('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 - - config_values[key] = Value('remote', default_value.get('value')) - - return ServerConfig(config_values) - - -class ServerConfig: - """Represents a Remote Config Server Side Config.""" - - def __init__(self, config_values): - self._config_values = config_values - - def get_boolean(self, key): - return self._config_values[key].as_boolean() - - def get_string(self, key): - return self._config_values[key].as_string() +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. - def get_int(self, key): - return int(self._config_values[key].as_number()) + 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. - def get_value(self, key): - return self._config_values[key] - -class Value: + Returns: + ServerTemplate: An object having the cached server template to be used for evaluation. """ - Represents a value fetched from Remote Config. + 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 + +class _Value: + """Represents a value fetched from Remote Config. """ DEFAULT_VALUE_FOR_BOOLEAN = False DEFAULT_VALUE_FOR_STRING = '' @@ -563,8 +568,7 @@ class Value: BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on'] def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): - """ - Initializes a Value instance. + """Initializes a Value instance. Args: source: The source of the value (e.g., 'default', 'remote', 'static'). @@ -595,3 +599,4 @@ def as_number(self) -> float: def get_source(self) -> ValueSource: """Returns the source of the value.""" return self.source + \ No newline at end of file From b2d8b4f011c2a1a10382d32d1a88a67042b89eb0 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Sat, 2 Nov 2024 18:53:10 +0530 Subject: [PATCH 03/15] Add farmhash to extension whitelist pkg --- .pylintrc | 2 +- firebase_admin/_http_client.py | 8 ++++++++ firebase_admin/remote_config.py | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index 2155853c7..cac6d3943 100644 --- a/.pylintrc +++ b/.pylintrc @@ -32,7 +32,7 @@ unsafe-load-any-extension=no # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -extension-pkg-whitelist= +extension-pkg-whitelist= farmhash # Allow optimization of some AST trees. This will activate a peephole AST # optimizer, which will apply various small optimizations. For instance, it can diff --git a/firebase_admin/_http_client.py b/firebase_admin/_http_client.py index d259faddf..ab6c6b954 100644 --- a/firebase_admin/_http_client.py +++ b/firebase_admin/_http_client.py @@ -148,3 +148,11 @@ def __init__(self, **kwargs): def parse_body(self, resp): return resp.json() + +class RemoteConfigApiClient(HttpClient): + """An HTTP client that parses response messages as JSON.""" + def __init__(self, **kwargs): + HttpClient.__init__(self, **kwargs) + def parse_body(self, resp): + return resp.json() + \ No newline at end of file diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 4411db4de..f3e52c575 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -392,7 +392,7 @@ def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: Returns: The hashed value. """ - hash64 = farmhash.hash64withseed(seeded_randomization_id) + hash64 = farmhash.fingerprint64(seeded_randomization_id) return abs(hash64) def evaluate_custom_signal_condition(self, custom_signal_condition, context) -> bool: @@ -599,4 +599,3 @@ def as_number(self) -> float: def get_source(self) -> ValueSource: """Returns the source of the value.""" return self.source - \ No newline at end of file From 76b9b5fdb0d7d52d0bd1d887ddba78e48c27f3ad Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Sat, 2 Nov 2024 19:22:20 +0530 Subject: [PATCH 04/15] Replace farmhash to hashlib --- .pylintrc | 2 +- firebase_admin/remote_config.py | 6 ++++-- requirements.txt | 3 +-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pylintrc b/.pylintrc index cac6d3943..f9549f93b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -32,7 +32,7 @@ unsafe-load-any-extension=no # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -extension-pkg-whitelist= farmhash +extension-pkg-whitelist= # Allow optimization of some AST trees. This will activate a peephole AST # optimizer, which will apply various small optimizations. For instance, it can diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index f3e52c575..4b83fef8b 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -21,7 +21,7 @@ from typing import Dict, Optional, Literal, Callable, Union from enum import Enum import re -import farmhash +import hashlib from firebase_admin import App, _http_client, _utils import firebase_admin @@ -392,7 +392,9 @@ def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: Returns: The hashed value. """ - hash64 = farmhash.fingerprint64(seeded_randomization_id) + hash_object = hashlib.sha256() + hash_object.update(seeded_randomization_id) + hash64 = hash_object.hexdigest() return abs(hash64) def evaluate_custom_signal_condition(self, custom_signal_condition, context) -> bool: diff --git a/requirements.txt b/requirements.txt index c726b758a..acf09438b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,4 @@ google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != ' google-api-python-client >= 1.7.8 google-cloud-firestore >= 2.9.1; platform.python_implementation != 'PyPy' google-cloud-storage >= 1.37.1 -pyjwt[crypto] >= 2.5.0 -pyfarmhash >= 0.4.0 \ No newline at end of file +pyjwt[crypto] >= 2.5.0 \ No newline at end of file From c63da12ef1ab885437b7963b36f9437ba0624b56 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Mon, 4 Nov 2024 13:46:27 +0530 Subject: [PATCH 05/15] Added unit testcase --- firebase_admin/remote_config.py | 175 ++++---- tests/test_remote_config.py | 684 ++++++++++++++++++++++++++++++++ tests/testutils.py | 45 +++ 3 files changed, 821 insertions(+), 83 deletions(-) create mode 100644 tests/test_remote_config.py diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 4b83fef8b..c51e84f88 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -32,6 +32,13 @@ _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. @@ -52,6 +59,7 @@ class CustomSignalOperator(Enum): 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: """Represents a Server Template Data class.""" @@ -131,13 +139,13 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser Call load() before calling evaluate().""") context = context or {} config_values = {} - # Initializes config Value objects with default values. - for key, value in self._stringified_default_config.items(): - config_values[key] = _Value('default', value) - - self._evaluator = _ConditionEvaluator(self._cache.conditions, context, - config_values, self._cache.parameters) + if self._stringified_default_config is not None: + for key, value in json.loads(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): @@ -156,13 +164,13 @@ 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_number() def get_value(self, key): return self._config_values[key] @@ -209,7 +217,7 @@ def _get_url_prefix(self): class _ConditionEvaluator: """Internal class that facilitates sending requests to the Firebase Remote Config backend API.""" - def __init__(self, context, conditions, config_values, parameters): + def __init__(self, conditions, parameters, context, config_values): self._context = context self._conditions = conditions self._parameters = parameters @@ -221,51 +229,53 @@ def evaluate(self): evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) # Overlays config Value objects derived by evaluating the template. - for key, parameter in self._parameters.items(): - conditional_values = parameter.conditional_values or {} - default_value = parameter.default_value or {} - 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. - 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.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')) + # evaluated_conditions = None + if self._parameters is not None: + 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 is not None: + 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, named_conditions, context)-> Dict[str, bool]: - """Evaluates a list of named conditions and returns a dictionary of results. + def evaluate_conditions(self, conditions, context)-> Dict[str, bool]: + """Evaluates a list of conditions and returns a dictionary of results. Args: - named_conditions: A list of NamedCondition objects. + conditions: A list of NamedCondition objects. context: An EvaluationContext object. Returns: A dictionary mapping condition names to boolean evaluation results. """ evaluated_conditions = {} - for named_condition in named_conditions: - evaluated_conditions[named_condition.name] = self.evaluate_condition( - named_condition.condition, context + for condition in conditions: + evaluated_conditions[condition.get('name')] = self.evaluate_condition( + condition.get('condition'), context ) return evaluated_conditions @@ -284,18 +294,20 @@ def evaluate_condition(self, condition, context, if nesting_level >= MAX_CONDITION_RECURSION_DEPTH: logger.warning("Maximum condition recursion depth exceeded.") return False - if condition.or_condition: - return self.evaluate_or_condition(condition.or_condition, context, nesting_level + 1) - if condition.and_condition: - return self.evaluate_and_condition(condition.and_condition, context, nesting_level + 1) - if condition.true_condition: + 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.false_condition: + if condition.get('false') is not None: return False - if condition.percent_condition: - return self.evaluate_percent_condition(condition.percent_condition, context) - if condition.custom_signal_condition: - return self.evaluate_custom_signal_condition(condition.custom_signal_condition, context) + 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 @@ -312,7 +324,7 @@ def evaluate_or_condition(self, or_condition, Returns: True if any of the subconditions are true, False otherwise. """ - sub_conditions = or_condition.conditions or [] + 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: @@ -332,7 +344,7 @@ def evaluate_and_condition(self, and_condition, Returns: True if all of the subconditions are true, False otherwise. """ - sub_conditions = and_condition.conditions or [] + 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: @@ -350,36 +362,33 @@ def evaluate_percent_condition(self, percent_condition, Returns: True if the condition is met, False otherwise. """ - if not context.randomization_id: + if not context.get('randomization_id'): logger.warning("Missing randomization ID for percent condition.") return False - seed = percent_condition.seed - percent_operator = percent_condition.percent_operator - micro_percent = percent_condition.micro_percent or 0 - micro_percent_range = percent_condition.micro_percent_range - + 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.micro_percent_upper_bound - norm_percent_lower_bound = micro_percent_range.micro_percent_lower_bound + 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 seed_prefix = f"{seed}." if seed else "" - string_to_hash = f"{seed_prefix}{context.randomization_id}" + string_to_hash = f"{seed_prefix}{context.get('randomization_id')}" hash64 = self.hash_seeded_randomization_id(string_to_hash) - - instance_micro_percentile = hash64 % (100 * 1_000_000) - - if percent_operator == "LESS_OR_EQUAL": + instance_micro_percentile = hash64 % (100 * 1000000) + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL: return instance_micro_percentile <= micro_percent - if percent_operator == "GREATER_THAN": + if percent_operator == PercentConditionOperator.GREATER_THAN: return instance_micro_percentile > micro_percent - if percent_operator == "BETWEEN": + 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 @@ -393,9 +402,9 @@ def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: The hashed value. """ hash_object = hashlib.sha256() - hash_object.update(seeded_randomization_id) + hash_object.update(seeded_randomization_id.encode('utf-8')) hash64 = hash_object.hexdigest() - return abs(hash64) + return abs(int(hash64, 16)) def evaluate_custom_signal_condition(self, custom_signal_condition, context) -> bool: """Evaluates a custom signal condition. @@ -407,15 +416,15 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, Returns: True if the condition is met, False otherwise. """ - custom_signal_operator = custom_signal_condition.custom_signal_operator - custom_signal_key = custom_signal_condition.custom_signal_key - target_custom_signal_values = custom_signal_condition.target_custom_signal_values + custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} + custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} + tgt_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]): + if not all([custom_signal_operator, custom_signal_key, tgt_custom_signal_values]): logger.warning("Missing operator, key, or target values for custom signal condition.") return False - if not target_custom_signal_values: + if not tgt_custom_signal_values: return False actual_custom_signal_value = getattr(context, custom_signal_key, None) if actual_custom_signal_value is None: @@ -466,14 +475,14 @@ def compare_strings(predicate_fn: Callable[[str, str], bool]) -> bool: bool: True if the predicate function returns True for any target value in the list, False otherwise. """ - for target in target_custom_signal_values: + for target in tgt_custom_signal_values: if predicate_fn(target, str(actual_custom_signal_value)): return True return False def compare_numbers(predicate_fn: Callable[[int], bool]) -> bool: try: - target = float(target_custom_signal_values[0]) + target = float(tgt_custom_signal_values[0]) actual = float(actual_custom_signal_value) result = -1 if actual < target else 1 if actual > target else 0 return predicate_fn(result) @@ -494,7 +503,7 @@ def compare_semantic_versions(predicate_fn: Callable[[int], bool]) -> bool: False otherwise. """ return compare_versions(str(actual_custom_signal_value), - str(target_custom_signal_values[0]), predicate_fn) + str(tgt_custom_signal_values[0]), predicate_fn) def compare_versions(version1: str, version2: str, predicate_fn: Callable[[int], bool]) -> bool: """Compares two semantic version strings. @@ -587,7 +596,7 @@ def as_boolean(self) -> bool: """Returns the value as a boolean.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_BOOLEAN - return self.value.lower() in self.BOOLEAN_TRUTHY_VALUES + return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES def as_number(self) -> float: """Returns the value as a number.""" diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py new file mode 100644 index 000000000..fa366d740 --- /dev/null +++ b/tests/test_remote_config.py @@ -0,0 +1,684 @@ +# 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 uuid +import firebase_admin +from firebase_admin.remote_config import ( + _REMOTE_CONFIG_ATTRIBUTE, + _RemoteConfigService, + PercentConditionOperator) +from firebase_admin import remote_config, _utils +from tests import testutils + + +VERSION_INFO = { + 'versionNumber': '86', + 'updateOrigin': 'ADMIN_SDK_NODE', + 'updateType': 'INCREMENTAL_UPDATE', + 'updateUser': { + 'email': 'firebase-adminsdk@gserviceaccount.com' + }, + 'description': 'production version', + 'updateTime': '2020-06-15T16: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 MockAdapter(testutils.MockAdapter): + """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" + + ETAG = '0' + + 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 TestGetServerTemplate: + _DEFAULT_APP = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + _RC_INSTANCE = _utils.get_app_service(_DEFAULT_APP, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + _DEFAULT_RESPONSE = json.dumps({ + 'parameters': { + 'test_key': 'test_value' + }, + 'conditions': {}, + 'parameterGroups': {}, + 'version': 'test' + }) + + def test_rc_instance_get_server_template(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + + template = self._RC_INSTANCE.get_server_template() + + assert template.parameters == dict(test_key="test_value") + assert str(template.version) == 'test' + + def test_rc_instance_return_conditional_values(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_rc_instance_return_conditional_values_true(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_rc_instance_return_conditional_values_honor_order(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'corgi' + + def test_rc_instance_return_conditional_values_honor_order_final(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'corgi' + + def test_rc_instance_evaluate_default_when_no_param(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + default_config = {'promo_enabled': False, 'promo_discount': 20,} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.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_rc_instance_evaluate_default_when_no_default_value(self): + recorder = [] + self._RC_INSTANCE._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + default_config = {'default_value': 'local default'} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'default_value': {} + } + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_string('default_value') == default_config.get('default_value') + + def test_rc_instance_evaluate_default_when_in_default(self): + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'remote_default_value': {} + } + default_config = { + 'inapp_default': '🐕' + } + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_string('inapp_default') == default_config.get('inapp_default') + + def test_rc_instance_evaluate_default_when_defined(self): + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + default_config = { + 'dog_type': 'shiba' + } + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.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_rc_instance_evaluate_return_numeric_value(self): + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_age': 12 + } + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_int('dog_age') == 12 + + def test_rc_instance_evaluate_return__value(self): + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_is_cute': True + } + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate() + assert server_config.get_int('dog_is_cute') + + def test_rc_instance_evaluate_unknown_operator_false(self): + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_rc_instance_evaluate_less_max_equal_true(self): + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_rc_instance_evaluate_min_max_equal_true(self): + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_rc_instance_evaluate_min_max_equal_false(self): + 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.ServerTemplate(self._DEFAULT_APP, default_config) + server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_rc_instance_evaluate_less_or_equal_approx(self): + 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 + } + + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + tolerance = 284 + assert truthy_assignments >= 10000 - tolerance + assert truthy_assignments <= 10000 + tolerance + + def test_rc_instance_evaluate_between_approx(self): + 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 + } + + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + tolerance = 379 + assert truthy_assignments >= 20000 - tolerance + assert truthy_assignments <= 20000 + tolerance + + def test_rc_instance_evaluate_between_interquartile_range_approx(self): + 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 + } + + server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + tolerance = 474 + assert truthy_assignments >= 50000 - tolerance + assert truthy_assignments <= 50000 + tolerance + + def evaluate_random_assignments(self, condition, num_of_assignments, server_template) -> int: + """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.set(remote_config.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 diff --git a/tests/testutils.py b/tests/testutils.py index ab4fb40cb..12c413989 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -218,3 +218,48 @@ 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, + # ... other relevant fields ... + } + +def build_mock_parameter(name, description, value=None, + conditional_values=None, default_value=None): + return { + 'name': name, + 'description': description, + 'value': value, + 'conditionalValues': conditional_values, + 'defaultValue': default_value, + # ... other relevant fields ... + } + +def build_mock_conditional_value(condition_name, value): + return { + 'conditionName': condition_name, + 'value': value, + # ... other relevant fields ... + } + +def build_mock_default_value(value): + return { + 'value': value, + # ... other relevant fields ... + } + +def build_mock_parameter_group(name, description, parameters): + return { + 'name': name, + 'description': description, + 'parameters': parameters, + # ... other relevant fields ... + } + +def build_mock_version(version_number): + return { + 'versionNumber': version_number, + # ... other relevant fields ... + } From 758d6e40f7f7754074c83aaf6d8da81783ace66d Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Mon, 4 Nov 2024 13:53:12 +0530 Subject: [PATCH 06/15] removed lint error --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index f9549f93b..2155853c7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -32,7 +32,7 @@ unsafe-load-any-extension=no # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -extension-pkg-whitelist= +extension-pkg-whitelist= # Allow optimization of some AST trees. This will activate a peephole AST # optimizer, which will apply various small optimizations. For instance, it can From 10dc901783c9b7eb46664af5f102f01c92d4484e Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Mon, 4 Nov 2024 19:14:08 +0530 Subject: [PATCH 07/15] add mock test --- firebase_admin/remote_config.py | 1 - tests/test_remote_config.py | 207 +++++++++++++++++++++----------- 2 files changed, 138 insertions(+), 70 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index c51e84f88..25e8125cb 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -133,7 +133,6 @@ def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'Ser ValueError: If the input arguments are invalid. """ # Logic to process the cached template into a ServerConfig here. - # TODO: Add Condition evaluator. if not self._cache: raise ValueError("""No Remote Config Server template in cache. Call load() before calling evaluate().""") diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index fa366d740..a60d4998e 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -15,15 +15,19 @@ """Tests for firebase_admin.remote_config.""" import json import uuid +from unittest import mock import firebase_admin from firebase_admin.remote_config import ( _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService, - PercentConditionOperator) + PercentConditionOperator, + ServerTemplateData) from firebase_admin import remote_config, _utils from tests import testutils + + VERSION_INFO = { 'versionNumber': '86', 'updateOrigin': 'ADMIN_SDK_NODE', @@ -92,22 +96,25 @@ class TestGetServerTemplate: 'version': 'test' }) - def test_rc_instance_get_server_template(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + def set_up(self): + # Create a more specific mock for firebase_admin.App + self.mock_app = mock.create_autospec(firebase_admin.App) + self.mock_app.project_id = 'mock-project-id' + self.mock_app.name = 'mock-app-name' + + # Mock initialize_app to return the mock App instance + self.mock_initialize_app = mock.patch('firebase_admin.initialize_app').start() + self.mock_initialize_app.return_value = self.mock_app - template = self._RC_INSTANCE.get_server_template() + # Mock the app registry + self.mock_get_app = mock.patch('firebase_admin._utils.get_app_service').start() + self.mock_get_app.return_value = self.mock_app - assert template.parameters == dict(test_key="test_value") - assert str(template.version) == 'test' + def tear_down(self): + mock.patch.stopall() def test_rc_instance_return_conditional_values(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { 'name': 'is_true', @@ -141,16 +148,18 @@ def test_rc_instance_return_conditional_values(self): 'version': '', 'etag': '123' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) + server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') + self.tear_down() def test_rc_instance_return_conditional_values_true(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { 'name': 'is_true', @@ -184,16 +193,18 @@ def test_rc_instance_return_conditional_values_true(self): 'version': '', 'etag': '123' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') + self.tear_down() + def test_rc_instance_return_conditional_values_honor_order(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} template_data = { 'conditions': [ @@ -249,16 +260,17 @@ def test_rc_instance_return_conditional_values_honor_order(self): 'version':'', 'etag': '123' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' + self.tear_down() def test_rc_instance_return_conditional_values_honor_order_final(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} template_data = { 'conditions': [ @@ -314,41 +326,48 @@ def test_rc_instance_return_conditional_values_honor_order_final(self): 'version':'', 'etag': '123' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' + self.tear_down() def test_rc_instance_evaluate_default_when_no_param(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'promo_enabled': False, 'promo_discount': 20,} template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = {} - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) 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') + self.tear_down() def test_rc_instance_evaluate_default_when_no_default_value(self): - recorder = [] - self._RC_INSTANCE._client.session.mount( - 'https://firebaseremoteconfig.googleapis.com', - MockAdapter(self._DEFAULT_RESPONSE, 200, recorder)) + self.set_up() default_config = {'default_value': 'local default'} template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = { 'default_value': {} } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_string('default_value') == default_config.get('default_value') + self.tear_down() def test_rc_instance_evaluate_default_when_in_default(self): + self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = { 'remote_default_value': {} @@ -356,44 +375,64 @@ def test_rc_instance_evaluate_default_when_in_default(self): default_config = { 'inapp_default': '🐕' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_string('inapp_default') == default_config.get('inapp_default') + self.tear_down() def test_rc_instance_evaluate_default_when_defined(self): + self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = {} default_config = { 'dog_type': 'shiba' } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) 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' + self.tear_down() def test_rc_instance_evaluate_return_numeric_value(self): + self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { 'dog_age': 12 } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_int('dog_age') == 12 + self.tear_down() def test_rc_instance_evaluate_return__value(self): + self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { 'dog_is_cute': True } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate() assert server_config.get_int('dog_is_cute') + self.tear_down() def test_rc_instance_evaluate_unknown_operator_false(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -426,12 +465,17 @@ def test_rc_instance_evaluate_unknown_operator_false(self): 'etag': '123' } context = {'randomization_id': '123'} - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') + self.tear_down() def test_rc_instance_evaluate_less_max_equal_true(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -466,12 +510,17 @@ def test_rc_instance_evaluate_less_max_equal_true(self): 'etag': '123' } context = {'randomization_id': '123'} - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') + self.tear_down() def test_rc_instance_evaluate_min_max_equal_true(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -509,12 +558,17 @@ def test_rc_instance_evaluate_min_max_equal_true(self): 'etag': '123' } context = {'randomization_id': '123'} - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') + self.tear_down() def test_rc_instance_evaluate_min_max_equal_false(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -552,12 +606,17 @@ def test_rc_instance_evaluate_min_max_equal_false(self): 'etag': '123' } context = {'randomization_id': '123'} - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') + self.tear_down() def test_rc_instance_evaluate_less_or_equal_approx(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -580,13 +639,15 @@ def test_rc_instance_evaluate_less_or_equal_approx(self): 'dog_is_cute': True } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + self.mock_app, default_config) tolerance = 284 assert truthy_assignments >= 10000 - tolerance assert truthy_assignments <= 10000 + tolerance + self.tear_down() def test_rc_instance_evaluate_between_approx(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -612,13 +673,15 @@ def test_rc_instance_evaluate_between_approx(self): 'dog_is_cute': True } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + self.mock_app, default_config) tolerance = 379 assert truthy_assignments >= 20000 - tolerance assert truthy_assignments <= 20000 + tolerance + self.tear_down() def test_rc_instance_evaluate_between_interquartile_range_approx(self): + self.set_up() condition = { 'name': 'is_true', 'condition': { @@ -644,13 +707,14 @@ def test_rc_instance_evaluate_between_interquartile_range_approx(self): 'dog_is_cute': True } - server_template = remote_config.ServerTemplate(self._DEFAULT_APP, default_config) - truthy_assignments = self.evaluate_random_assignments(condition, 100000, server_template) + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + self.mock_app, default_config) tolerance = 474 assert truthy_assignments >= 50000 - tolerance assert truthy_assignments <= 50000 + tolerance + self.tear_down() - def evaluate_random_assignments(self, condition, num_of_assignments, server_template) -> int: + def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config) -> int: """Evaluates random assignments based on a condition. Args: @@ -674,7 +738,12 @@ def evaluate_random_assignments(self, condition, num_of_assignments, server_temp 'version':'', 'etag': '123' } - server_template.set(remote_config.ServerTemplateData('etag', template_data)) + server_template = remote_config.init_server_template( + app=mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) + for _ in range(num_of_assignments): context = {'randomization_id': str(uuid.uuid4())} result = server_template.evaluate(context) From 5c355402f536a7dbaca60fc5dbb5328301406f59 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Mon, 4 Nov 2024 19:18:47 +0530 Subject: [PATCH 08/15] resolve lint comments --- tests/test_remote_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index a60d4998e..e702b27d5 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -714,7 +714,7 @@ def test_rc_instance_evaluate_between_interquartile_range_approx(self): assert truthy_assignments <= 50000 + tolerance self.tear_down() - def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config) -> int: + def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config): """Evaluates random assignments based on a condition. Args: From 55f2a0a2bd047f56654428019c8b9a078e86dc2e Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Wed, 6 Nov 2024 17:15:31 +0530 Subject: [PATCH 09/15] Fixed bug --- firebase_admin/_http_client.py | 8 -- firebase_admin/remote_config.py | 232 +++++++++++++++++++------------- tests/test_remote_config.py | 2 +- 3 files changed, 139 insertions(+), 103 deletions(-) diff --git a/firebase_admin/_http_client.py b/firebase_admin/_http_client.py index ab6c6b954..d259faddf 100644 --- a/firebase_admin/_http_client.py +++ b/firebase_admin/_http_client.py @@ -148,11 +148,3 @@ def __init__(self, **kwargs): def parse_body(self, resp): return resp.json() - -class RemoteConfigApiClient(HttpClient): - """An HTTP client that parses response messages as JSON.""" - def __init__(self, **kwargs): - HttpClient.__init__(self, **kwargs) - def parse_body(self, resp): - return resp.json() - \ No newline at end of file diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 25e8125cb..618264ce7 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -18,7 +18,7 @@ import json import logging -from typing import Dict, Optional, Literal, Callable, Union +from typing import Dict, Optional, Literal, Union from enum import Enum import re import hashlib @@ -228,15 +228,14 @@ def evaluate(self): evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) # Overlays config Value objects derived by evaluating the template. - # evaluated_conditions = None - if self._parameters is not None: + 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 is not None: + 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] @@ -404,6 +403,7 @@ def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: 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. @@ -417,124 +417,168 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, """ custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} - tgt_custom_signal_values = custom_signal_condition.get('target_custom_signal_values') or {} + target_custom_signal_values = ( + custom_signal_condition.get('target_custom_signal_values') or {}) - if not all([custom_signal_operator, custom_signal_key, tgt_custom_signal_values]): + 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 tgt_custom_signal_values: + if not target_custom_signal_values: return False - actual_custom_signal_value = getattr(context, custom_signal_key, None) - if actual_custom_signal_value is None: + 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 compare_strings(lambda target, actual: target in actual) + 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 compare_strings(lambda target, actual: target in actual) + 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 compare_strings(lambda target, actual: target.strip() == actual.strip()) + 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 compare_strings(lambda target, actual: re.search(target, actual) is not None) + 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 compare_numbers(lambda r: r < 0) + 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 compare_numbers(lambda r: r <= 0) + 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 compare_numbers(lambda r: r == 0) + 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 compare_numbers(lambda r: r != 0) + 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 compare_numbers(lambda r: r > 0) + 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 compare_numbers(lambda r: r >= 0) + 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 compare_semantic_versions(lambda r: r < 0) + 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 compare_semantic_versions(lambda r: r <= 0) + 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 compare_semantic_versions(lambda r: r == 0) + 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 compare_semantic_versions(lambda r: r != 0) + 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 compare_semantic_versions(lambda r: r > 0) + 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 compare_semantic_versions(lambda r: r >= 0) - - def compare_strings(predicate_fn: Callable[[str, str], bool]) -> bool: - """Compares the actual string value of a signal against a list of target values. - - Args: - 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 tgt_custom_signal_values: - if predicate_fn(target, str(actual_custom_signal_value)): - return True - return False + 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_numbers(predicate_fn: Callable[[int], bool]) -> bool: - try: - target = float(tgt_custom_signal_values[0]) - actual = float(actual_custom_signal_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_strings(self, target_values, actual_value, predicate_fn) -> bool: + """Compares the actual string value of a signal against a list of target values. - def compare_semantic_versions(predicate_fn: Callable[[int], bool]) -> 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: - 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 compare_versions(str(actual_custom_signal_value), - str(tgt_custom_signal_values[0]), predicate_fn) - def compare_versions(version1: str, version2: str, - predicate_fn: Callable[[int], bool]) -> 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 + 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. - logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + 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. diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index e702b27d5..66e2225d8 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -680,7 +680,7 @@ def test_rc_instance_evaluate_between_approx(self): assert truthy_assignments <= 20000 + tolerance self.tear_down() - def test_rc_instance_evaluate_between_interquartile_range_approx(self): + def test_rc_instance_evaluate_between_interquartile_range_accuracy(self): self.set_up() condition = { 'name': 'is_true', From ad0e13c4442b1d238818835d319790f1f197081e Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Wed, 6 Nov 2024 21:14:44 +0530 Subject: [PATCH 10/15] Added fixes --- firebase_admin/remote_config.py | 8 +- tests/test_remote_config.py | 235 +++++++++++++++++--------------- 2 files changed, 134 insertions(+), 109 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 618264ce7..0d92bbc52 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -377,15 +377,19 @@ def evaluate_percent_condition(self, percent_condition, 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 <= micro_percent + return instance_micro_percentile <= norm_micro_percent if percent_operator == PercentConditionOperator.GREATER_THAN: - return instance_micro_percentile > micro_percent + 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) diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 66e2225d8..49aa86338 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -13,31 +13,25 @@ # limitations under the License. """Tests for firebase_admin.remote_config.""" -import json import uuid from unittest import mock import firebase_admin from firebase_admin.remote_config import ( - _REMOTE_CONFIG_ATTRIBUTE, - _RemoteConfigService, PercentConditionOperator, ServerTemplateData) -from firebase_admin import remote_config, _utils -from tests import testutils - - - +from firebase_admin import remote_config VERSION_INFO = { 'versionNumber': '86', - 'updateOrigin': 'ADMIN_SDK_NODE', + 'updateOrigin': 'ADMIN_SDK_PYTHON', 'updateType': 'INCREMENTAL_UPDATE', 'updateUser': { 'email': 'firebase-adminsdk@gserviceaccount.com' }, 'description': 'production version', - 'updateTime': '2020-06-15T16:45:03.541527Z' + 'updateTime': '2024-11-05T16:45:03.541527Z' } + SERVER_REMOTE_CONFIG_RESPONSE = { 'conditions': [ { @@ -68,34 +62,7 @@ 'version': VERSION_INFO, } -class MockAdapter(testutils.MockAdapter): - """A Mock HTTP Adapter that Firebase Remote Config with ETag in header.""" - - ETAG = '0' - - 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 TestGetServerTemplate: - _DEFAULT_APP = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') - _RC_INSTANCE = _utils.get_app_service(_DEFAULT_APP, - _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) - _DEFAULT_RESPONSE = json.dumps({ - 'parameters': { - 'test_key': 'test_value' - }, - 'conditions': {}, - 'parameterGroups': {}, - 'version': 'test' - }) - +class TestEvaluate: def set_up(self): # Create a more specific mock for firebase_admin.App self.mock_app = mock.create_autospec(firebase_admin.App) @@ -113,7 +80,7 @@ def set_up(self): def tear_down(self): mock.patch.stopall() - def test_rc_instance_return_conditional_values(self): + def test_evaluate_or_and_true_condition_true(self): self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { @@ -158,7 +125,7 @@ def test_rc_instance_return_conditional_values(self): assert server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_return_conditional_values_true(self): + def test_evaluate_or_and_false_condition_false(self): self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { @@ -171,7 +138,7 @@ def test_rc_instance_return_conditional_values_true(self): 'conditions': [ { 'name': '', - 'true': { + 'false': { } } ] @@ -198,66 +165,31 @@ def test_rc_instance_return_conditional_values_true(self): default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) + server_config = server_template.evaluate() - assert server_config.get_boolean('is_enabled') + assert not server_config.get_boolean('is_enabled') self.tear_down() - - def test_rc_instance_return_conditional_values_honor_order(self): + def test_evaluate_non_or_condition(self): self.set_up() 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': { - } - } - ] - } - } - ] - } - } + condition = { + 'name': 'is_true', + 'condition': { + 'true': { } - ], + } + } + template_data = { + 'conditions': [condition], 'parameters': { - 'dog_type': { - 'defaultValue': {'value': 'chihuahua'}, - 'conditionalValues': { - 'is_true_too': {'value': 'dachshund'}, - 'is_true': {'value': 'corgi'} - } + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} }, }, - 'parameterGroups':'', - 'version':'', + 'parameterGroups': '', + 'version': '', 'etag': '123' } server_template = remote_config.init_server_template( @@ -265,11 +197,12 @@ def test_rc_instance_return_conditional_values_honor_order(self): default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) + server_config = server_template.evaluate() - assert server_config.get_string('dog_type') == 'corgi' + assert server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_return_conditional_values_honor_order_final(self): + def test_evaluate_return_conditional_values_honor_order(self): self.set_up() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} template_data = { @@ -335,7 +268,7 @@ def test_rc_instance_return_conditional_values_honor_order_final(self): assert server_config.get_string('dog_type') == 'corgi' self.tear_down() - def test_rc_instance_evaluate_default_when_no_param(self): + def test_evaluate_default_when_no_param(self): self.set_up() default_config = {'promo_enabled': False, 'promo_discount': 20,} template_data = SERVER_REMOTE_CONFIG_RESPONSE @@ -350,7 +283,7 @@ def test_rc_instance_evaluate_default_when_no_param(self): assert server_config.get_int('promo_discount') == default_config.get('promo_discount') self.tear_down() - def test_rc_instance_evaluate_default_when_no_default_value(self): + def test_evaluate_default_when_no_default_value(self): self.set_up() default_config = {'default_value': 'local default'} template_data = SERVER_REMOTE_CONFIG_RESPONSE @@ -366,7 +299,7 @@ def test_rc_instance_evaluate_default_when_no_default_value(self): assert server_config.get_string('default_value') == default_config.get('default_value') self.tear_down() - def test_rc_instance_evaluate_default_when_in_default(self): + def test_evaluate_default_when_in_default(self): self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = { @@ -384,7 +317,7 @@ def test_rc_instance_evaluate_default_when_in_default(self): assert server_config.get_string('inapp_default') == default_config.get('inapp_default') self.tear_down() - def test_rc_instance_evaluate_default_when_defined(self): + def test_evaluate_default_when_defined(self): self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = {} @@ -401,7 +334,7 @@ def test_rc_instance_evaluate_default_when_defined(self): assert server_config.get_value('dog_type').get_source() == 'default' self.tear_down() - def test_rc_instance_evaluate_return_numeric_value(self): + def test_evaluate_return_numeric_value(self): self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { @@ -416,7 +349,7 @@ def test_rc_instance_evaluate_return_numeric_value(self): assert server_config.get_int('dog_age') == 12 self.tear_down() - def test_rc_instance_evaluate_return__value(self): + def test_evaluate_return__value(self): self.set_up() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { @@ -431,7 +364,7 @@ def test_rc_instance_evaluate_return__value(self): assert server_config.get_int('dog_is_cute') self.tear_down() - def test_rc_instance_evaluate_unknown_operator_false(self): + def test_evaluate_unknown_operator_to_false(self): self.set_up() condition = { 'name': 'is_true', @@ -474,7 +407,7 @@ def test_rc_instance_evaluate_unknown_operator_false(self): assert not server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_evaluate_less_max_equal_true(self): + def test_evaluate_less_or_equal_to_max_to_true(self): self.set_up() condition = { 'name': 'is_true', @@ -519,7 +452,95 @@ def test_rc_instance_evaluate_less_max_equal_true(self): assert server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_evaluate_min_max_equal_true(self): + def test_evaluate_undefined_micropercent_to_false(self): + self.set_up() + 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=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + self.tear_down() + + def test_evaluate_undefined_micropercentrange_to_false(self): + self.set_up() + 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=self.mock_app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + self.tear_down() + + def test_evaluate_between_min_max_to_true(self): self.set_up() condition = { 'name': 'is_true', @@ -567,7 +588,7 @@ def test_rc_instance_evaluate_min_max_equal_true(self): assert server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_evaluate_min_max_equal_false(self): + def test_evaluate_between_equal_bounds_to_false(self): self.set_up() condition = { 'name': 'is_true', @@ -615,7 +636,7 @@ def test_rc_instance_evaluate_min_max_equal_false(self): assert not server_config.get_boolean('is_enabled') self.tear_down() - def test_rc_instance_evaluate_less_or_equal_approx(self): + def test_evaluate_less_or_equal_to_approx(self): self.set_up() condition = { 'name': 'is_true', @@ -646,7 +667,7 @@ def test_rc_instance_evaluate_less_or_equal_approx(self): assert truthy_assignments <= 10000 + tolerance self.tear_down() - def test_rc_instance_evaluate_between_approx(self): + def test_evaluate_between_approx(self): self.set_up() condition = { 'name': 'is_true', @@ -680,7 +701,7 @@ def test_rc_instance_evaluate_between_approx(self): assert truthy_assignments <= 20000 + tolerance self.tear_down() - def test_rc_instance_evaluate_between_interquartile_range_accuracy(self): + def test_evaluate_between_interquartile_range_accuracy(self): self.set_up() condition = { 'name': 'is_true', From 9ff9600539133c744f1b0c9b915f21bc41c5f318 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Wed, 6 Nov 2024 21:19:01 +0530 Subject: [PATCH 11/15] Added fixe --- tests/testutils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/testutils.py b/tests/testutils.py index 12c413989..e2f58d5d7 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -234,6 +234,7 @@ def build_mock_parameter(name, description, value=None, 'value': value, 'conditionalValues': conditional_values, 'defaultValue': default_value, + 'parameterGroups': parameter_groups, # ... other relevant fields ... } From 4cd1eb14b2fbebb8871e6e92a89c9556014262e5 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Wed, 6 Nov 2024 21:22:10 +0530 Subject: [PATCH 12/15] Added fix for lint --- tests/testutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testutils.py b/tests/testutils.py index e2f58d5d7..3893a3463 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -227,7 +227,7 @@ def build_mock_condition(name, condition): } def build_mock_parameter(name, description, value=None, - conditional_values=None, default_value=None): + conditional_values=None, default_value=None, parameter_groups=None): return { 'name': name, 'description': description, From 11bb8dc7fc8d11fe5a05d80380366f10804866fd Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Thu, 7 Nov 2024 19:46:44 +0530 Subject: [PATCH 13/15] Changed structure of test --- tests/test_remote_config.py | 119 ++++++++++++++---------------------- 1 file changed, 46 insertions(+), 73 deletions(-) diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 49aa86338..5cba92ce5 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -14,12 +14,12 @@ """Tests for firebase_admin.remote_config.""" import uuid -from unittest import mock import firebase_admin from firebase_admin.remote_config import ( PercentConditionOperator, ServerTemplateData) from firebase_admin import remote_config +from tests import testutils VERSION_INFO = { 'versionNumber': '86', @@ -63,25 +63,17 @@ } class TestEvaluate: - def set_up(self): - # Create a more specific mock for firebase_admin.App - self.mock_app = mock.create_autospec(firebase_admin.App) - self.mock_app.project_id = 'mock-project-id' - self.mock_app.name = 'mock-app-name' + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) - # Mock initialize_app to return the mock App instance - self.mock_initialize_app = mock.patch('firebase_admin.initialize_app').start() - self.mock_initialize_app.return_value = self.mock_app - - # Mock the app registry - self.mock_get_app = mock.patch('firebase_admin._utils.get_app_service').start() - self.mock_get_app.return_value = self.mock_app - - def tear_down(self): - mock.patch.stopall() + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() def test_evaluate_or_and_true_condition_true(self): - self.set_up() + app = firebase_admin.get_app() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { 'name': 'is_true', @@ -116,17 +108,16 @@ def test_evaluate_or_and_true_condition_true(self): 'etag': '123' } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_or_and_false_condition_false(self): - self.set_up() + app = firebase_admin.get_app() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { 'name': 'is_true', @@ -161,17 +152,16 @@ def test_evaluate_or_and_false_condition_false(self): 'etag': '123' } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert not server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_non_or_condition(self): - self.set_up() + app = firebase_admin.get_app() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} condition = { 'name': 'is_true', @@ -193,17 +183,16 @@ def test_evaluate_non_or_condition(self): 'etag': '123' } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_return_conditional_values_honor_order(self): - self.set_up() + app = firebase_admin.get_app() default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} template_data = { 'conditions': [ @@ -260,47 +249,44 @@ def test_evaluate_return_conditional_values_honor_order(self): 'etag': '123' } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' - self.tear_down() def test_evaluate_default_when_no_param(self): - self.set_up() + 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=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) 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') - self.tear_down() def test_evaluate_default_when_no_default_value(self): - self.set_up() + 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=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_string('default_value') == default_config.get('default_value') - self.tear_down() def test_evaluate_default_when_in_default(self): - self.set_up() + app = firebase_admin.get_app() template_data = SERVER_REMOTE_CONFIG_RESPONSE template_data['parameters'] = { 'remote_default_value': {} @@ -309,63 +295,59 @@ def test_evaluate_default_when_in_default(self): 'inapp_default': '🐕' } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_string('inapp_default') == default_config.get('inapp_default') - self.tear_down() def test_evaluate_default_when_defined(self): - self.set_up() + 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=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) 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' - self.tear_down() def test_evaluate_return_numeric_value(self): - self.set_up() + app = firebase_admin.get_app() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { 'dog_age': 12 } server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_int('dog_age') == 12 - self.tear_down() def test_evaluate_return__value(self): - self.set_up() + 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=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate() assert server_config.get_int('dog_is_cute') - self.tear_down() def test_evaluate_unknown_operator_to_false(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -399,16 +381,15 @@ def test_evaluate_unknown_operator_to_false(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_less_or_equal_to_max_to_true(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -444,16 +425,15 @@ def test_evaluate_less_or_equal_to_max_to_true(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_undefined_micropercent_to_false(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -488,16 +468,15 @@ def test_evaluate_undefined_micropercent_to_false(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_undefined_micropercentrange_to_false(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -532,16 +511,15 @@ def test_evaluate_undefined_micropercentrange_to_false(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_between_min_max_to_true(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -580,16 +558,15 @@ def test_evaluate_between_min_max_to_true(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_between_equal_bounds_to_false(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -628,16 +605,15 @@ def test_evaluate_between_equal_bounds_to_false(self): } context = {'randomization_id': '123'} server_template = remote_config.init_server_template( - app=self.mock_app, + app=app, default_config=default_config, template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') - self.tear_down() def test_evaluate_less_or_equal_to_approx(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -661,14 +637,13 @@ def test_evaluate_less_or_equal_to_approx(self): } truthy_assignments = self.evaluate_random_assignments(condition, 100000, - self.mock_app, default_config) + app, default_config) tolerance = 284 assert truthy_assignments >= 10000 - tolerance assert truthy_assignments <= 10000 + tolerance - self.tear_down() def test_evaluate_between_approx(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -695,14 +670,13 @@ def test_evaluate_between_approx(self): } truthy_assignments = self.evaluate_random_assignments(condition, 100000, - self.mock_app, default_config) + app, default_config) tolerance = 379 assert truthy_assignments >= 20000 - tolerance assert truthy_assignments <= 20000 + tolerance - self.tear_down() def test_evaluate_between_interquartile_range_accuracy(self): - self.set_up() + app = firebase_admin.get_app() condition = { 'name': 'is_true', 'condition': { @@ -729,11 +703,10 @@ def test_evaluate_between_interquartile_range_accuracy(self): } truthy_assignments = self.evaluate_random_assignments(condition, 100000, - self.mock_app, default_config) + app, default_config) tolerance = 474 assert truthy_assignments >= 50000 - tolerance assert truthy_assignments <= 50000 + tolerance - self.tear_down() def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config): """Evaluates random assignments based on a condition. From c736126bb4c688bd0b3b195fde91c6e36f809230 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Fri, 15 Nov 2024 13:57:22 +0530 Subject: [PATCH 14/15] Added fix for comments --- tests/testutils.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/testutils.py b/tests/testutils.py index 3893a3463..17013b469 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -223,7 +223,6 @@ def build_mock_condition(name, condition): return { 'name': name, 'condition': condition, - # ... other relevant fields ... } def build_mock_parameter(name, description, value=None, @@ -235,20 +234,17 @@ def build_mock_parameter(name, description, value=None, 'conditionalValues': conditional_values, 'defaultValue': default_value, 'parameterGroups': parameter_groups, - # ... other relevant fields ... } def build_mock_conditional_value(condition_name, value): return { 'conditionName': condition_name, 'value': value, - # ... other relevant fields ... } def build_mock_default_value(value): return { 'value': value, - # ... other relevant fields ... } def build_mock_parameter_group(name, description, parameters): @@ -256,11 +252,9 @@ def build_mock_parameter_group(name, description, parameters): 'name': name, 'description': description, 'parameters': parameters, - # ... other relevant fields ... } def build_mock_version(version_number): return { 'versionNumber': version_number, - # ... other relevant fields ... } From 1c27d548e4496aa072120648637615ed4b3229a8 Mon Sep 17 00:00:00 2001 From: Varun Rathore <varunrathore@google.com> Date: Fri, 15 Nov 2024 18:56:26 +0530 Subject: [PATCH 15/15] Added fix for comments --- firebase_admin/remote_config.py | 29 +++++++++++++++++-------- tests/test_remote_config.py | 38 ++++++++++++++++----------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index 0d92bbc52..6cc63b94b 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -169,10 +169,15 @@ def get_string(self, key): return self.get_value(key).as_string() def get_int(self, key): - return self.get_value(key).as_number() + 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: @@ -622,7 +627,8 @@ class _Value: """ DEFAULT_VALUE_FOR_BOOLEAN = False DEFAULT_VALUE_FOR_STRING = '' - DEFAULT_VALUE_FOR_NUMBER = 0 + 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): @@ -637,6 +643,8 @@ def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): 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: @@ -645,14 +653,17 @@ def as_boolean(self) -> bool: return self.DEFAULT_VALUE_FOR_BOOLEAN return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES - def as_number(self) -> float: + def as_int(self) -> float: """Returns the value as a number.""" if self.source == 'static': - return self.DEFAULT_VALUE_FOR_NUMBER - try: - return float(self.value) - except ValueError: - return self.DEFAULT_VALUE_FOR_NUMBER + 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.""" diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 5cba92ce5..b503ca28f 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -110,7 +110,7 @@ def test_evaluate_or_and_true_condition_true(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() @@ -154,7 +154,7 @@ def test_evaluate_or_and_false_condition_false(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() @@ -185,7 +185,7 @@ def test_evaluate_non_or_condition(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() @@ -251,7 +251,7 @@ def test_evaluate_return_conditional_values_honor_order(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_string('dog_type') == 'corgi' @@ -264,7 +264,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') @@ -280,7 +280,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_string('default_value') == default_config.get('default_value') @@ -297,7 +297,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_string('inapp_default') == default_config.get('inapp_default') @@ -312,7 +312,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_value('dog_type').as_string() == 'shiba' @@ -327,12 +327,12 @@ 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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() assert server_config.get_int('dog_age') == 12 - def test_evaluate_return__value(self): + def test_evaluate_return_boolean_value(self): app = firebase_admin.get_app() template_data = SERVER_REMOTE_CONFIG_RESPONSE default_config = { @@ -341,10 +341,10 @@ def test_evaluate_return__value(self): server_template = remote_config.init_server_template( app=app, default_config=default_config, - template_data=ServerTemplateData('etag', template_data) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() - assert server_config.get_int('dog_is_cute') + assert server_config.get_boolean('dog_is_cute') def test_evaluate_unknown_operator_to_false(self): app = firebase_admin.get_app() @@ -383,7 +383,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -427,7 +427,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -470,7 +470,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -513,7 +513,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -560,7 +560,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert server_config.get_boolean('is_enabled') @@ -607,7 +607,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate(context) assert not server_config.get_boolean('is_enabled') @@ -735,7 +735,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) # Use ServerTemplateData here + template_data=ServerTemplateData('etag', template_data) ) for _ in range(num_of_assignments):