Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (audience match types): Update condition evaluator for new audience match types #146

Conversation

oakbani
Copy link
Contributor

@oakbani oakbani commented Oct 25, 2018

Summary

This adds support for new audience match type conditions to the condition evaluator:

  • exact, exists, gt, lt, and substring conditions
  • Abort and return null when appropriate in leaf evaluators
  • Null handling in and/or/not evaluators
  • Assume implicit "or" when operator is missing

Test Plan

Added new unit tests.
Ran compatibility suite tests for typed audiences


def assertStrictFalse(self, to_assert):
self.assertIs(to_assert, False)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertTrue and assertFalse check for truthy and falsy values.
Added these to ensure that we don't mistake null for false

@coveralls
Copy link

coveralls commented Oct 25, 2018

Coverage Status

Coverage increased (+0.02%) to 99.693% when pulling 88159da on oakbani/audience-match-type-condition-evaluator-2 into e5ec118 on oakbani/audience-match-types.

@@ -0,0 +1,15 @@
Summary
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this changes are coming in this PR? @oakbani

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated base branch with master

@aliabbasrizvi
Copy link
Contributor

I think compat tests are now available. You can update the test plan.

Copy link
Contributor

@aliabbasrizvi aliabbasrizvi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sort of a big change. Will try and finish review today.

result = self.evaluate(conditions[0], leaf_evaluator)
return None if result is None else not result

DEFAULT_OPERATOR_TYPES = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Put these dicts after line 33.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aliabbasrizvi that might work better if these methods are all declared with @staticmethod.

Hmm, since ConditionTreeEvaluator actually doesn't contain any state, I wonder if we should just break it out into a separate module with top-level functions. As in the JS SDK. This might also be a preferable way to namespace when we add support for other audience condition types, other condition trees (e.g. audience combinations), ...

@oakbani what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. I don't think DEFAULT_... needs to be in the name of this enum.

And arguably we could get rid of this enum in favor of EVALUATORS_BY_OPERATOR_TYPE!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if we should create condition as a separate module and keep each of these classes in separate files for better organization. @nchilada Yeah we can make them static.
@aliabbasrizvi What do you suggest? The changes will propagate to project config and combinations work(which has been done). I hope we are not in an urgency to merge this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference is to make them separate modules with their own top-level functions. From a code organization standpoint I am ok if all classes stay in this file itself.

Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @oakbani! Thanks for waiting so long 😬

I've spent too much time in JavaScript-land but this seems like really good Python. I only left minor feedback, the most obnoxious being a potential reorganization to reduce the amount of class-based namespacing and more closely match the JS SDK.

I'll look at the unit tests in my next pass of review!

optimizely/helpers/audience.py Show resolved Hide resolved
optimizely/helpers/condition.py Outdated Show resolved Hide resolved
@@ -159,7 +392,12 @@ def _audience_condition_deserializer(obj_dict):
Returns:
List consisting of condition key and corresponding value.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of date!

optimizely/helpers/condition.py Outdated Show resolved Hide resolved
result = self.evaluate(conditions[0], leaf_evaluator)
return None if result is None else not result

DEFAULT_OPERATOR_TYPES = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aliabbasrizvi that might work better if these methods are all declared with @staticmethod.

Hmm, since ConditionTreeEvaluator actually doesn't contain any state, I wonder if we should just break it out into a separate module with top-level functions. As in the JS SDK. This might also be a preferable way to namespace when we add support for other audience condition types, other condition trees (e.g. audience combinations), ...

@oakbani what do you think?


# Compare types if one of the values is bool because bool is a subclass on Integer
if isinstance(first_val, bool) or isinstance(second_val, bool):
return first_val_type == second_val_type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simpler, I think, and matches the structure of the numeric block:

if isinstance(first_val, bool) and isinstance(second_val, bool):
  return True

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case if only 1 of values is a bool type and the other is float or int
it will return true in the following if block as bool is a subclass of int

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, you're right. So tricky! I haven't looked at the unit tests yet but hopefully we have test cases for this. The compatibility suite can't test the specifics of Python data types

if isinstance(first_val, string_types) and isinstance(second_val, string_types):
return True

# Compare types if one of the values is bool because bool is a subclass on Integer
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think the key detail here is that we have to check bool before we check for arbitrary numbers. Could we find a way to mention this in the comment?


return condition_value in user_value

EVALUATORS_BY_MATCH_TYPE = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing @aliabbasrizvi would want this, too, to be declared above?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙇

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose can't declare it before defining evaluator methods

optimizely/helpers/condition.py Outdated Show resolved Hide resolved
optimizely/helpers/condition.py Outdated Show resolved Hide resolved
Copy link
Contributor

@aliabbasrizvi aliabbasrizvi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good. Left some comments.

Boolean:
- True if all operands evaluate to True
- False if a single operand evaluates to False
None: if conditions couldn't be evaluated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Might be useful to leave a comment here explaining when this would be the case. Thoughts on example @nchilada ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Would definitely get verbose. Perhaps we could just force ourselves to be diligent in documenting the new, collective behavior somewhere else? E.g. in the KB or at the very top of the condition tree module (rather than separately for each operator's evaluator)

- True if any operand evaluates to True
- False if all operands evaluate to False
None: if conditions couldn't be evaluated

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Remove this extraneous line.

- True if the operand evaluates to False
- False if the operand evaluates to True
None: if conditions is empty or condition couldn't be evaluated

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Remove this extraneous line.

optimizely/helpers/condition.py Outdated Show resolved Hide resolved
result = self.evaluate(conditions[0], leaf_evaluator)
return None if result is None else not result

DEFAULT_OPERATOR_TYPES = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference is to make them separate modules with their own top-level functions. From a code organization standpoint I am ok if all classes stay in this file itself.

condition_value = self.condition_data[index][1]
user_value = self.attributes.get(self.condition_data[index][0])

if not self.is_finite(condition_value) or not self.is_finite(user_value):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If self.is_finite(condition_value) is False, then that suggests a potential problem in the way the audience is set up.
@nchilada do we have log messages to help customers identify the problem?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, not self.is_finite(condition_value) is degenerate (though still worth checking).

We're refining our log messages as a separate initiative...it's looking like we're more concerned about logging problems that customers are actually expected to encounter, but we'll see. Can I add you as a reviewer on the other doc that I'm talking about?


return condition_value in user_value

EVALUATORS_BY_MATCH_TYPE = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙇

'name': 'browser_type',
'value': 'safari',
'type': 'custom_attribute',
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. This indentation seems off.

Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! I'll try to skim through the tests late today.

optimizely/helpers/condition.py Show resolved Hide resolved
optimizely/helpers/validator.py Outdated Show resolved Hide resolved
if math.isnan(value) or math.isinf(value):
return False

return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for extracting this! As mentioned on a now-obsolete commit, can we

  1. enforce an absolute size limit of 1e53 if isinstance(value, numbers.Integral)
  2. update helpers.validator.is_attribute_valid such that it calls helpers.validator.is_finite_number

?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And @aliabbasrizvi's addendum:

Random. I am wondering if we should extend this validation i.e. the is_finite check to revenue and quantity values?

+1, is worth considering

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do this in a separate commit.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume by "separate commit" you mean "separate PR"? That's fine, thanks! Tracking in 3654


# Compare types if one of the values is bool because bool is a subclass on Integer
if isinstance(first_val, bool) or isinstance(second_val, bool):
return first_val_type == second_val_type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, you're right. So tricky! I haven't looked at the unit tests yet but hopefully we have test cases for this. The compatibility suite can't test the specifics of Python data types

Boolean:
- True if all operands evaluate to True
- False if a single operand evaluates to False
None: if conditions couldn't be evaluated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Would definitely get verbose. Perhaps we could just force ourselves to be diligent in documenting the new, collective behavior somewhere else? E.g. in the KB or at the very top of the condition tree module (rather than separately for each operator's evaluator)

condition_value = self.condition_data[index][1]
user_value = self.attributes.get(self.condition_data[index][0])

if not self.is_finite(condition_value) or not self.is_finite(user_value):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, not self.is_finite(condition_value) is degenerate (though still worth checking).

We're refining our log messages as a separate initiative...it's looking like we're more concerned about logging problems that customers are actually expected to encounter, but we'll see. Can I add you as a reviewer on the other doc that I'm talking about?

optimizely/helpers/condition.py Outdated Show resolved Hide resolved
optimizely/helpers/audience.py Show resolved Hide resolved
optimizely/helpers/condition_tree_evaluator.py Outdated Show resolved Hide resolved
Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @oakbani! Code looks good. I just have a couple minor requests for the test cases

class ConditionEvaluatorTests(base.BaseTest):
exists_condition_list = [['input_value', None, 'custom_attribute', 'exists']]
exact_string_condition_list = [['favorite_constellation', 'Lacerta', 'custom_attribute', 'exact']]
exact_number_condition_list = [['lasers_count', 9000, 'custom_attribute', 'exact']]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also have a test for exact number match where the condition value is a float? I see the doubleCondition above, but it looks like that's used for something else

def test_exact_number__returns_true__when_user_provided_value_is_equal_to_condition_value(self):

evaluator = condition_helper.CustomAttributeConditionEvaluator(
exact_number_condition_list, {'lasers_count': 9000}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also find a way to test with 9000L in the case of Python 2?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually...do we run our unit tests in both Python 2 and Python 3?? @aliabbasrizvi


self.assertIsNone(evaluator.evaluate(0))

def test_exact_number__returns_true__when_user_provided_value_is_equal_to_condition_value(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have two versions of this:

  • test_exact_int_returns_true__when_user_provided_value_is_equal_to_condition_value
  • test_exact_float_returns_true__when_user_provided_value_is_equal_to_condition_value
    with the same assertions but different condition value types.

Same for most or all of the other exact_number, greater_than, and less_than tests!

exact_bool_condition_list = [['did_register_user', False, 'custom_attribute', 'exact']]
substring_condition_list = [['headline_text', 'buy now', 'custom_attribute', 'substring']]
gt_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'gt']]
lt_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'lt']]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also have int versions of the above two conditions?

Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stellar work. Thanks @oakbani!

tests/helpers_tests/test_condition.py Outdated Show resolved Hide resolved
Co-Authored-By: oakbani <owais.akbani92@gmail.com>
Copy link
Contributor

@aliabbasrizvi aliabbasrizvi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Requesting some minor changes.


class ConditionalOperatorTypes(object):
from .validator import is_finite_number, are_values_same_type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. I personally prefer doing something like:

from . import validator and then call the respective method.


Args:
condition: Integer representing the index of condition_data that needs to be used for comparison.
value: Value to validate
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Period (.) at the end of line. Please make sure to do so in all messages in this PR.


Returns:
Boolean indicating the result of comparing the condition value against the user attributes.
Boolean: True if value is a string type, or a boolean, or is finite. Otherwise False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. Same as above.


Args:
conditions: List of conditions ex: [operand_1, operand_2]
index: Index of the condition to be evaluated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index is a built in Python method I think. Please rename to something else.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Objects have a special method named __index__, but no global index variable, I don't think. See the global functions reference in 2 and 3.

""" Evaluate the given exists match condition for the user attributes

Args:
index: Index of the condition to be evaluated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above. Please update all instances of index in this PR to be something different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually clean after all!

@@ -120,13 +120,13 @@ def test_init(self):
'11154', 'Test attribute users 1',
'["and", ["or", ["or", {"name": "test_attribute", "type": "custom_attribute", "value": "test_value_1"}]]]',
conditionStructure=['and', ['or', ['or', 0]]],
conditionList=[['test_attribute', 'test_value_1']]
conditionList=[['test_attribute', 'test_value_1', 'custom_attribute', None]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't updated project_config.py code. How is this getting set to None?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have edited def _audience_condition_deserializer(obj_dict): to add type and match to attribute node so that we use it in CustomAttributeConditionEvaluator - evaluate()
https://github.com/optimizely/python-sdk/pull/146/files#diff-54739e7fb9eb3054932a2f39734e8676R218-R232

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants