diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py index c975b7c1..e0a80b48 100644 --- a/firebase_admin/remote_config.py +++ b/firebase_admin/remote_config.py @@ -1,4 +1,4 @@ -# Copyright 2017 Google Inc. +# Copyright 2024 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -197,7 +197,7 @@ def get_float(self, key): return self.get_value(key).as_float() def get_value(self, key): - if self._config_values[key]: + if key in self._config_values: return self._config_values[key] return _Value('static') @@ -421,11 +421,11 @@ def evaluate_percent_condition(self, percent_condition, hash64 = self.hash_seeded_randomization_id(string_to_hash) instance_micro_percentile = hash64 % (100 * 1000000) - if percent_operator == PercentConditionOperator.LESS_OR_EQUAL: + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL.value: return instance_micro_percentile <= norm_micro_percent - if percent_operator == PercentConditionOperator.GREATER_THAN: + if percent_operator == PercentConditionOperator.GREATER_THAN.value: return instance_micro_percentile > norm_micro_percent - if percent_operator == PercentConditionOperator.BETWEEN: + if percent_operator == PercentConditionOperator.BETWEEN.value: return norm_percent_lower_bound < instance_micro_percentile <= norm_percent_upper_bound logger.warning("Unknown percent operator: %s", percent_operator) return False @@ -454,10 +454,10 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, Returns: True if the condition is met, False otherwise. """ - custom_signal_operator = custom_signal_condition.get('custom_signal_operator') or {} - custom_signal_key = custom_signal_condition.get('custom_signal_key') or {} + custom_signal_operator = custom_signal_condition.get('customSignalOperator') or {} + custom_signal_key = custom_signal_condition.get('customSignalKey') or {} target_custom_signal_values = ( - custom_signal_condition.get('target_custom_signal_values') or {}) + custom_signal_condition.get('targetCustomSignalValues') or {}) if not all([custom_signal_operator, custom_signal_key, target_custom_signal_values]): logger.warning("Missing operator, key, or target values for custom signal condition.") @@ -471,71 +471,71 @@ def evaluate_custom_signal_condition(self, custom_signal_condition, logger.warning("Custom signal value not found in context: %s", custom_signal_key) return False - if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS: + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target in actual) - if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN: + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN.value: return not self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target in actual) - if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES: + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, lambda target, actual: target.strip() == actual.strip()) - if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX: + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX.value: return self._compare_strings(target_custom_signal_values, actual_custom_signal_value, re.search) # For numeric operators only one target value is allowed. - if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) - if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL.value: return self._compare_numbers(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) # For semantic operators only one target value is allowed. - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r < 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r <= 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r == 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r != 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r > 0) - if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL: + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL.value: return self._compare_semantic_versions(target_custom_signal_values[0], actual_custom_signal_value, lambda r: r >= 0) @@ -593,9 +593,9 @@ 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. + 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. @@ -608,6 +608,8 @@ def _compare_versions(self, version1, version2, predicate_fn) -> bool: v2_parts.extend([0] * (max_length - len(v2_parts))) for part1, part2 in zip(v1_parts, v2_parts): + if any((part1 < 0, part2 < 0)): + raise ValueError if part1 < part2: return predicate_fn(-1) if part1 > part2: @@ -674,7 +676,7 @@ def as_string(self) -> str: """Returns the value as a string.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_STRING - return self.value + return str(self.value) def as_boolean(self) -> bool: """Returns the value as a boolean.""" @@ -686,13 +688,19 @@ def as_int(self) -> float: """Returns the value as a number.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_INTEGER - return self.value + try: + return int(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_INTEGER def as_float(self) -> float: """Returns the value as a number.""" if self.source == 'static': return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER - return float(self.value) + try: + return float(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER def get_source(self) -> ValueSource: """Returns the source of the value.""" diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 914b99cb..15f6ef09 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -1,4 +1,4 @@ -# Copyright 2017 Google Inc. +# Copyright 2024 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import pytest import firebase_admin from firebase_admin.remote_config import ( + CustomSignalOperator, PercentConditionOperator, _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService, @@ -66,6 +67,17 @@ 'version': VERSION_INFO, } +SEMENTIC_VERSION_LESS_THAN_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.443', True] +SEMENTIC_VERSION_EQUAL_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value, ['12.1.3.444'], '12.1.3.444', True] +SEMANTIC_VERSION_GREATER_THAN_FALSE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.4'], '12.1.3.4', False] +SEMANTIC_VERSION_INVALID_FORMAT_STRING = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.abc', False] +SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.-2', False] + class TestEvaluate: @classmethod def setup_class(cls): @@ -272,7 +284,7 @@ def test_evaluate_default_when_no_param(self): ) server_config = server_template.evaluate() assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') - assert server_config.get_int('promo_discount') == default_config.get('promo_discount') + assert server_config.get_int('promo_discount') == int(default_config.get('promo_discount')) def test_evaluate_default_when_no_default_value(self): app = firebase_admin.get_app() @@ -334,7 +346,7 @@ def test_evaluate_return_numeric_value(self): template_data=ServerTemplateData('etag', template_data) ) server_config = server_template.evaluate() - assert server_config.get_int('dog_age') == default_config.get('dog_age') + assert server_config.get_int('dog_age') == int(default_config.get('dog_age')) def test_evaluate_return_boolean_value(self): app = firebase_admin.get_app() @@ -360,7 +372,7 @@ def test_evaluate_unknown_operator_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.UNKNOWN + 'percentOperator': PercentConditionOperator.UNKNOWN.value } }], } @@ -402,7 +414,7 @@ def test_evaluate_less_or_equal_to_max_to_true(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, 'seed': 'abcdef', 'microPercent': 100_000_000 } @@ -446,7 +458,7 @@ def test_evaluate_undefined_micropercent_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, # Leaves microPercent undefined } }], @@ -489,7 +501,7 @@ def test_evaluate_undefined_micropercentrange_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, # Leaves microPercent undefined } }], @@ -532,7 +544,7 @@ def test_evaluate_between_min_max_to_true(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 0, @@ -579,7 +591,7 @@ def test_evaluate_between_equal_bounds_to_false(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 50000000, @@ -626,7 +638,7 @@ def test_evaluate_less_or_equal_to_approx(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL, + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, 'seed': 'abcdef', 'microPercent': 10_000_000 # 10% } @@ -656,7 +668,7 @@ def test_evaluate_between_approx(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 40_000_000, @@ -689,7 +701,7 @@ def test_evaluate_between_interquartile_range_accuracy(self): 'andCondition': { 'conditions': [{ 'percent': { - 'percentOperator': PercentConditionOperator.BETWEEN, + 'percentOperator': PercentConditionOperator.BETWEEN.value, 'seed': 'abcdef', 'microPercentRange': { 'microPercentLowerBound': 25_000_000, @@ -708,7 +720,7 @@ def test_evaluate_between_interquartile_range_accuracy(self): truthy_assignments = self.evaluate_random_assignments(condition, 100000, app, default_config) - tolerance = 474 + tolerance = 490 assert truthy_assignments >= 50000 - tolerance assert truthy_assignments <= 50000 + tolerance @@ -750,6 +762,64 @@ def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, d return eval_true_count + @pytest.mark.parametrize( + 'custom_signal_opearator, \ + target_custom_signal_value, actual_custom_signal_value, parameter_value', + [ + SEMENTIC_VERSION_LESS_THAN_TRUE, + SEMANTIC_VERSION_GREATER_THAN_FALSE, + SEMENTIC_VERSION_EQUAL_TRUE, + SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER, + SEMANTIC_VERSION_INVALID_FORMAT_STRING + ]) + def test_evaluate_custom_signal_semantic_version(self, + custom_signal_opearator, + target_custom_signal_value, + actual_custom_signal_value, + parameter_value): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'customSignal': { + 'customSignalOperator': custom_signal_opearator, + 'customSignalKey': 'sementic_version_key', + 'targetCustomSignalValues': target_custom_signal_value + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123', 'sementic_version_key': actual_custom_signal_value} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data=ServerTemplateData('etag', template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') == parameter_value + class MockAdapter(testutils.MockAdapter): """A Mock HTTP Adapter that Firebase Remote Config with ETag in header."""