Skip to content

Commit a6ba86d

Browse files
committed
don't force users to pass user object attributes as strings
1 parent 7ad2ca9 commit a6ba86d

File tree

6 files changed

+239
-147
lines changed

6 files changed

+239
-147
lines changed

Diff for: configcatclient/evaluationcontext.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ def __init__(self,
44
user,
55
visited_keys=None,
66
is_missing_user_object_logged=False,
7-
is_missing_user_object_attribute_logged=False,
8-
type_mismatched_logged_user_attributes=None):
7+
is_missing_user_object_attribute_logged=False):
98
self.key = key
109
self.user = user
1110
self.visited_keys = visited_keys
1211
self.is_missing_user_object_logged = is_missing_user_object_logged
1312
self.is_missing_user_object_attribute_logged = is_missing_user_object_attribute_logged
14-
self.type_mismatched_logged_user_attributes = type_mismatched_logged_user_attributes or []

Diff for: configcatclient/rolloutevaluator.py

+65-41
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
from .evaluationcontext import EvaluationContext
1414
from .evaluationlogbuilder import EvaluationLogBuilder
1515
from .logger import Logger
16+
from datetime import datetime
1617

1718
from .user import User
18-
from .utils import unicode_to_utf8, encode_utf8
19+
from .utils import unicode_to_utf8, encode_utf8, get_seconds_since_epoch
1920

2021

2122
def sha256(value_utf8, salt, context_salt):
@@ -142,26 +143,42 @@ def _format_rule(self, comparison_attribute, comparator, comparison_value):
142143
% (comparison_attribute, comparator_text,
143144
EvaluationLogBuilder.trunc_comparison_value_if_needed(comparator, comparison_value))
144145

145-
def _get_user_attribute(self, context, user, attribute):
146-
user_attribute = user.get_attribute(attribute)
147-
if user_attribute is not None:
148-
try:
149-
string_types = (str, unicode) # Python 2.7
150-
except NameError:
151-
string_types = (str,) # Python 3.x
152-
153-
if not isinstance(user_attribute, string_types):
154-
if attribute not in context.type_mismatched_logged_user_attributes:
155-
self.log.warning('Evaluation of setting \'%s\' may not produce the expected result '
156-
'(the User.%s attribute is not a string value, thus it was converted to \'%s\' '
157-
'using the runtime\'s default conversion). User Object attribute values should be '
158-
'passed as strings. You can use the static helper methods of the `User` class to '
159-
'produce attribute values with the correct type and format.',
160-
context.key, attribute, user_attribute, event_id=4004)
161-
context.type_mismatched_logged_user_attributes.add(attribute)
162-
user_attribute = str(user_attribute)
163-
164-
return user_attribute
146+
def _user_attribute_value_to_string(self, value):
147+
if value is None:
148+
return None
149+
150+
if isinstance(value, datetime):
151+
value = self._get_user_attribute_value_as_seconds_since_epoch(value)
152+
elif isinstance(value, list):
153+
value = self.get_user_attribute_value_as_string_list(value)
154+
155+
return str(value)
156+
157+
def _get_user_attribute_value_as_text(self, attribute_name, attribute_value, condition, key):
158+
if isinstance(attribute_value, str):
159+
return attribute_value
160+
161+
self.log.warning('Evaluation of condition (%s) for setting \'%s\' may not produce the expected result '
162+
'(the User.%s attribute is not a string value, thus it was automatically converted to '
163+
'the string value \'%s\'). Please make sure that using a non-string value was intended.',
164+
condition, key, attribute_name, attribute_value, event_id=3005)
165+
return self._user_attribute_value_to_string(attribute_value)
166+
167+
def _get_user_attribute_value_as_seconds_since_epoch(self, attribute_value):
168+
if isinstance(attribute_value, datetime):
169+
return get_seconds_since_epoch(attribute_value)
170+
171+
return float(str(attribute_value).replace(",", "."))
172+
173+
def get_user_attribute_value_as_string_list(self, attribute_value):
174+
if not isinstance(attribute_value, list):
175+
attribute_value_list = json.loads(attribute_value)
176+
else:
177+
attribute_value_list = attribute_value
178+
if not isinstance(attribute_value_list, list):
179+
raise ValueError()
180+
181+
return attribute_value_list
165182

166183
def _handle_invalid_user_attribute(self, comparison_attribute, comparator, comparison_value, key, validation_error):
167184
"""
@@ -201,7 +218,7 @@ def _evaluate_percentage_options(self, percentage_options, context, percentage_r
201218

202219
user_attribute_name = percentage_rule_attribute if percentage_rule_attribute is not None else 'Identifier'
203220
if percentage_rule_attribute is not None:
204-
user_key = self._get_user_attribute(context, user, percentage_rule_attribute)
221+
user_key = user.get_attribute(percentage_rule_attribute)
205222
else:
206223
user_key = user.get_identifier()
207224
if percentage_rule_attribute is not None and user_key is None:
@@ -218,7 +235,7 @@ def _evaluate_percentage_options(self, percentage_options, context, percentage_r
218235
'Skipping %% options because the User.%s attribute is missing.' % user_attribute_name)
219236
return False, None, None, None
220237

221-
hash_candidate = ('%s%s' % (key, user_key)).encode('utf-8')
238+
hash_candidate = ('%s%s' % (key, self._user_attribute_value_to_string(user_key))).encode('utf-8')
222239
hash_val = int(hashlib.sha1(hash_candidate).hexdigest()[:7], 16) % 100
223240

224241
bucket = 0
@@ -463,13 +480,14 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
463480
comparison_attribute = user_condition.get(COMPARISON_ATTRIBUTE)
464481
comparator = user_condition.get(COMPARATOR)
465482
comparison_value = user_condition.get(COMPARISON_VALUES[comparator])
483+
condition = self._format_rule(comparison_attribute, comparator, comparison_value)
466484
error = None
467485

468486
if comparison_attribute is None:
469487
raise ValueError('Comparison attribute name is missing.')
470488

471489
if log_builder:
472-
log_builder.append(self._format_rule(comparison_attribute, comparator, comparison_value) + ' ')
490+
log_builder.append(condition + ' ')
473491

474492
if user is None:
475493
if not context.is_missing_user_object_logged:
@@ -483,33 +501,35 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
483501
error = 'cannot evaluate, User Object is missing'
484502
return False, error
485503

486-
user_value = self._get_user_attribute(context, user, comparison_attribute)
504+
user_value = user.get_attribute(comparison_attribute)
487505
if user_value is None or not user_value:
488506
self.log.warning('Cannot evaluate condition (%s) for setting \'%s\' '
489507
'(the User.%s attribute is missing). You should set the User.%s attribute in order to make '
490508
'targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/',
491-
self._format_rule(comparison_attribute, comparator, comparison_value), key,
492-
comparison_attribute, comparison_attribute,
493-
event_id=3003)
509+
condition, key, comparison_attribute, comparison_attribute, event_id=3003)
494510
error = 'cannot evaluate, the User.{} attribute is missing'.format(comparison_attribute)
495511
return False, error
496512

497513
# IS ONE OF
498514
if comparator == Comparator.IS_ONE_OF:
499-
if str(user_value) in comparison_value:
515+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
516+
if user_value in comparison_value:
500517
return True, error
501518
# IS NOT ONE OF
502519
elif comparator == Comparator.IS_NOT_ONE_OF:
503-
if str(user_value) not in comparison_value:
520+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
521+
if user_value not in comparison_value:
504522
return True, error
505523
# CONTAINS ANY OF
506524
elif comparator == Comparator.CONTAINS_ANY_OF:
525+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
507526
for comparison in comparison_value:
508-
if str(comparison) in str(user_value):
527+
if comparison in user_value:
509528
return True, error
510529
# NOT CONTAINS ANY OF
511530
elif comparator == Comparator.NOT_CONTAINS_ANY_OF:
512-
if not any(str(comparison) in str(user_value) for comparison in comparison_value):
531+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
532+
if not any(comparison in user_value for comparison in comparison_value):
513533
return True, error
514534
# IS ONE OF, IS NOT ONE OF (Semantic version)
515535
elif Comparator.IS_ONE_OF_SEMVER <= comparator <= Comparator.IS_NOT_ONE_OF_SEMVER:
@@ -557,16 +577,18 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
557577
return True, error
558578
# IS ONE OF (hashed)
559579
elif comparator == Comparator.IS_ONE_OF_HASHED:
580+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
560581
if sha256(encode_utf8(user_value), salt, context_salt) in comparison_value:
561582
return True, error
562583
# IS NOT ONE OF (hashed)
563584
elif comparator == Comparator.IS_NOT_ONE_OF_HASHED:
585+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
564586
if sha256(encode_utf8(user_value), salt, context_salt) not in comparison_value:
565587
return True, error
566588
# BEFORE, AFTER (UTC datetime)
567589
elif Comparator.BEFORE_DATETIME <= comparator <= Comparator.AFTER_DATETIME:
568590
try:
569-
user_value_float = float(str(user_value).replace(",", "."))
591+
user_value_float = self._get_user_attribute_value_as_seconds_since_epoch(user_value)
570592
except ValueError:
571593
validation_error = "'%s' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)" % \
572594
str(user_value)
@@ -581,17 +603,20 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
581603
return True, error
582604
# EQUALS (hashed)
583605
elif comparator == Comparator.EQUALS_HASHED:
606+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
584607
if sha256(encode_utf8(user_value), salt, context_salt) == comparison_value:
585608
return True, error
586609
# NOT EQUALS (hashed)
587610
elif comparator == Comparator.NOT_EQUALS_HASHED:
611+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
588612
if sha256(encode_utf8(user_value), salt, context_salt) != comparison_value:
589613
return True, error
590614
# STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF (hashed)
591615
elif Comparator.STARTS_WITH_ANY_OF_HASHED <= comparator <= Comparator.NOT_ENDS_WITH_ANY_OF_HASHED:
592616
for comparison in comparison_value:
593617
underscore_index = comparison.index('_')
594618
length = int(comparison[:underscore_index])
619+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
595620
user_value_utf8 = encode_utf8(user_value)
596621

597622
if len(user_value_utf8) >= length:
@@ -613,14 +638,12 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
613638
# ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF (hashed)
614639
elif Comparator.ARRAY_CONTAINS_ANY_OF_HASHED <= comparator <= Comparator.ARRAY_NOT_CONTAINS_ANY_OF_HASHED:
615640
try:
616-
user_value_list = json.loads(user_value)
617-
if not isinstance(user_value_list, list):
618-
raise ValueError()
641+
user_value_list = self.get_user_attribute_value_as_string_list(user_value)
619642

620643
if sys.version_info[0] == 2:
621644
user_value_list = unicode_to_utf8(user_value_list) # On Python 2.7, convert unicode to utf-8
622645
except ValueError:
623-
validation_error = "'%s' is not a valid JSON string array" % str(user_value)
646+
validation_error = "'%s' is not a valid string array" % str(user_value)
624647
error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key,
625648
validation_error)
626649
return False, error
@@ -637,14 +660,17 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
637660
return True, error
638661
# EQUALS
639662
elif comparator == Comparator.EQUALS:
663+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
640664
if user_value == comparison_value:
641665
return True, error
642666
# NOT EQUALS
643667
elif comparator == Comparator.NOT_EQUALS:
668+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
644669
if user_value != comparison_value:
645670
return True, error
646671
# STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF
647672
elif Comparator.STARTS_WITH_ANY_OF <= comparator <= Comparator.NOT_ENDS_WITH_ANY_OF:
673+
user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key)
648674
for comparison in comparison_value:
649675
if (comparator == Comparator.STARTS_WITH_ANY_OF and user_value.startswith(comparison)) or \
650676
(comparator == Comparator.ENDS_WITH_ANY_OF and user_value.endswith(comparison)):
@@ -659,14 +685,12 @@ def _evaluate_user_condition(self, user_condition, context, context_salt, salt,
659685
# ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF
660686
elif Comparator.ARRAY_CONTAINS_ANY_OF <= comparator <= Comparator.ARRAY_NOT_CONTAINS_ANY_OF:
661687
try:
662-
user_value_list = json.loads(user_value)
663-
if not isinstance(user_value_list, list):
664-
raise ValueError()
688+
user_value_list = self.get_user_attribute_value_as_string_list(user_value)
665689

666690
if sys.version_info[0] == 2:
667691
user_value_list = unicode_to_utf8(user_value_list) # On Python 2.7, convert unicode to utf-8
668692
except ValueError:
669-
validation_error = "'%s' is not a valid JSON string array" % str(user_value)
693+
validation_error = "'%s' is not a valid string array" % str(user_value)
670694
error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key,
671695
validation_error)
672696
return False, error

0 commit comments

Comments
 (0)