Ejemplo n.º 1
0
class SplitterGetTreatmentForBucket(TestCase):
    def setUp(self):
        self.some_partitions = [
            mock.MagicMock(),
            mock.MagicMock(),
            mock.MagicMock()
        ]
        self.some_partitions[0].size = 10
        self.some_partitions[1].size = 20
        self.some_partitions[2].size = 30
        self.splitter = Splitter()

    def test_returns_control_if_bucket_is_not_covered(self):
        """
        Tests that get_treatment_for_bucket returns CONTROL if the bucket is over the segments
        covered by the partition
        """
        self.assertEqual(
            CONTROL,
            self.splitter.get_treatment_for_bucket(100, self.some_partitions))

    def test_returns_treatment_of_partition_that_has_bucket(self):
        """
        Tests that get_treatment_for_bucket returns the treatment of the partition that covers the
        bucket.
        """
        self.assertEqual(
            self.some_partitions[0].treatment,
            self.splitter.get_treatment_for_bucket(1, self.some_partitions))
        self.assertEqual(
            self.some_partitions[1].treatment,
            self.splitter.get_treatment_for_bucket(15, self.some_partitions))
        self.assertEqual(
            self.some_partitions[2].treatment,
            self.splitter.get_treatment_for_bucket(33, self.some_partitions))
Ejemplo n.º 2
0
class SplitterGetBucketUnitTests(TestCase):
    def setUp(self):
        self.splitter = Splitter()

    def test_with_sample_data(self):
        """
        Tests hash_key against expected values using alphanumeric values
        """
        with open(join(dirname(__file__), 'sample-data.jsonl')) as f:
            for line in map(loads, f):
                seed, key, hash_, bucket = line
                self.assertEqual(
                    int(bucket),
                    self.splitter.get_bucket(key, seed, HashAlgorithm.LEGACY))

    # This test is being skipped because apparently LEGACY hash for
    # non-alphanumeric keys isn't working properly.
    # TODO: Discuss with @sarrubia whether we should raise ticket for this.
    @skip
    def test_with_non_alpha_numeric_sample_data(self):
        """
        Tests hash_key against expected values using non alphanumeric values
        """
        with open(
                join(dirname(__file__),
                     'sample-data-non-alpha-numeric.jsonl')) as f:
            for line in map(loads, f):
                seed, key, hash_, bucket = line
                self.assertEqual(
                    int(bucket),
                    self.splitter.get_bucket(key, seed, HashAlgorithm.LEGACY))
Ejemplo n.º 3
0
 def setUp(self):
     self.some_key = mock.MagicMock()
     self.some_seed = mock.MagicMock()
     self.some_partitions = [mock.MagicMock(), mock.MagicMock()]
     self.splitter = Splitter()
     #        self.hash_key_mock = self.patch_object(self.splitter, 'hash_key')
     self.get_bucket_mock = self.patch_object(self.splitter, 'get_bucket')
     self.get_treatment_for_bucket_mock = self.patch_object(
         self.splitter, 'get_treatment_for_bucket')
Ejemplo n.º 4
0
 def __init__(self, broker, labels_enabled=True):
     """Basic interface of a Client. Specific implementations need to override the
     get_split_fetcher method (and optionally the get_splitter method).
     """
     self._logger = logging.getLogger(self.__class__.__name__)
     self._splitter = Splitter()
     self._broker = broker
     self._labels_enabled = labels_enabled
     self._destroyed = False
Ejemplo n.º 5
0
 def setUp(self):
     self.some_partitions = [
         mock.MagicMock(),
         mock.MagicMock(),
         mock.MagicMock()
     ]
     self.some_partitions[0].size = 10
     self.some_partitions[1].size = 20
     self.some_partitions[2].size = 30
     self.splitter = Splitter()
Ejemplo n.º 6
0
class SplitterGetTreatmentDistributionTests(TestCase):
    def setUp(self):
        self.splitter = Splitter()

    def test_1_percent_treatments_evenly_distributed(self):
        """Test that get_treatment distributes treatments according to partitions"""
        seed = randint(-2147483649, 2147483648)
        partitions = [Partition(mock.MagicMock(), 1) for _ in range(100)]
        n = 100000
        p = 0.01

        treatments = [
            self.splitter.get_treatment(
                random_alphanumeric_string(randint(16, 32)), seed, partitions,
                HashAlgorithm.LEGACY) for _ in range(n)
        ]
        counter = Counter(treatments)

        mean = n * p
        stddev = sqrt(mean * (1 - p))

        count_min = int(mean - 4 * stddev)
        count_max = int(mean + 4 * stddev)

        for count in counter.values():
            self.assertTrue(count_min <= count <= count_max)
Ejemplo n.º 7
0
    def __init__(self, broker, labels_enabled=True, impression_listener=None):
        """
        Construct a Client instance.

        :param broker: Broker that accepts/retrieves splits, segments, events, metrics & impressions
        :type broker: BaseBroker

        :param labels_enabled: Whether to store labels on impressions
        :type labels_enabled: bool

        :param impression_listener: impression listener implementation
        :type impression_listener: ImpressionListener

        :rtype: Client
        """
        self._logger = logging.getLogger(self.__class__.__name__)
        self._splitter = Splitter()
        self._broker = broker
        self._labels_enabled = labels_enabled
        self._destroyed = False
        self._impression_listener = impression_listener
Ejemplo n.º 8
0
class SplitterGetTreatmentTests(TestCase, MockUtilsMixin):
    def setUp(self):
        self.some_key = mock.MagicMock()
        self.some_seed = mock.MagicMock()
        self.some_partitions = [mock.MagicMock(), mock.MagicMock()]
        self.splitter = Splitter()
        #        self.hash_key_mock = self.patch_object(self.splitter, 'hash_key')
        self.get_bucket_mock = self.patch_object(self.splitter, 'get_bucket')
        self.get_treatment_for_bucket_mock = self.patch_object(
            self.splitter, 'get_treatment_for_bucket')

    def test_get_treatment_returns_control_if_partitions_is_none(self):
        """Test that get_treatment returns the control treatment if partitions is None"""
        self.assertEqual(
            CONTROL,
            self.splitter.get_treatment(self.some_key, self.some_seed, None,
                                        HashAlgorithm.LEGACY))

    def test_get_treatment_returns_control_if_partitions_is_empty(self):
        """Test that get_treatment returns the control treatment if partitions is empty"""
        self.assertEqual(
            CONTROL,
            self.splitter.get_treatment(self.some_key, self.some_seed, [],
                                        HashAlgorithm.LEGACY))

    def test_get_treatment_returns_only_partition_treatment_if_it_is_100(self):
        """Test that get_treatment returns the only partition treatment if it is 100%"""
        some_partition = mock.MagicMock()
        some_partition.size = 100
        self.assertEqual(
            some_partition.treatment,
            self.splitter.get_treatment(self.some_key, self.some_seed,
                                        [some_partition],
                                        HashAlgorithm.LEGACY))

    def test_get_treatment_calls_get_treatment_for_bucket_if_more_than_1_partition(
            self):
        """
        Test that get_treatment calls get_treatment_for_bucket if there is more than one
        partition
        """
        self.splitter.get_treatment(self.some_key, self.some_seed,
                                    self.some_partitions, HashAlgorithm.LEGACY)
        self.get_treatment_for_bucket_mock.assert_called_once_with(
            self.get_bucket_mock.return_value, self.some_partitions)

    def test_get_treatment_returns_get_treatment_for_bucket_result_if_more_than_1_partition(
            self):
        """
        Test that get_treatment returns the result of callling get_treatment_for_bucket if there
        is more than one partition
        """
        self.assertEqual(
            self.get_treatment_for_bucket_mock.return_value,
            self.splitter.get_treatment(self.some_key, self.some_seed,
                                        self.some_partitions,
                                        HashAlgorithm.LEGACY))

    def test_get_treatment_calls_hash_key_if_more_than_1_partition(self):
        """
        Test that get_treatment calls hash_key if there is more than one partition
        """
        self.splitter.get_treatment(self.some_key, self.some_seed,
                                    self.some_partitions, HashAlgorithm.LEGACY)
#        self.hash_key_mock.assert_called_once_with(self.some_key, self.some_seed)

    def test_get_treatment_calls_get_bucket_if_more_than_1_partition(self):
        """
        Test that get_treatment calls get_bucket if there is more than one partition
        """
        self.splitter.get_treatment(self.some_key, self.some_seed,
                                    self.some_partitions, HashAlgorithm.LEGACY)
Ejemplo n.º 9
0
 def setUp(self):
     self.splitter = Splitter()
Ejemplo n.º 10
0
class Client(object):
    """Client class that uses a broker for storage."""
    def __init__(self, broker, labels_enabled=True, impression_listener=None):
        """
        Construct a Client instance.

        :param broker: Broker that accepts/retrieves splits, segments, events, metrics & impressions
        :type broker: BaseBroker

        :param labels_enabled: Whether to store labels on impressions
        :type labels_enabled: bool

        :param impression_listener: impression listener implementation
        :type impression_listener: ImpressionListener

        :rtype: Client
        """
        self._logger = logging.getLogger(self.__class__.__name__)
        self._splitter = Splitter()
        self._broker = broker
        self._labels_enabled = labels_enabled
        self._destroyed = False
        self._impression_listener = impression_listener

    def destroy(self):
        """
        Disable the split-client and free all allocated resources.

        Only applicable when using in-memory operation mode.
        """
        self._destroyed = True
        self._broker.destroy()

    def _send_impression_to_listener(self, impression, attributes):
        '''
        Sends impression result to custom listener.

        :param impression: Generated impression
        :type impression: Impression

        :param attributes: An optional dictionary of attributes
        :type attributes: dict
        '''
        if self._impression_listener is not None:
            try:
                self._impression_listener.log_impression(
                    impression, attributes)
            except ImpressionListenerException as e:
                self._logger.exception(e)

    def get_treatment(self, key, feature, attributes=None):
        """
        Get the treatment for a feature and key, with an optional dictionary of attributes.

        This method never raises an exception. If there's a problem, the appropriate log message
        will be generated and the method will return the CONTROL treatment.

        :param key: The key for which to get the treatment
        :type key: str
        :param feature: The name of the feature for which to get the treatment
        :type feature: str
        :param attributes: An optional dictionary of attributes
        :type attributes: dict
        :return: The treatment for the key and feature
        :rtype: str
        """
        if self._destroyed:
            self._logger.warning(
                "Client has already been destroyed, returning CONTROL")
            return CONTROL

        start = int(round(time.time() * 1000))

        matching_key, bucketing_key = input_validator.validate_key(key)
        feature = input_validator.validate_feature_name(feature)

        if (matching_key is None and bucketing_key is None) or feature is None:
            impression = self._build_impression(matching_key, feature, CONTROL,
                                                Label.EXCEPTION, 0,
                                                bucketing_key, start)
            self._record_stats(impression, start, SDK_GET_TREATMENT)
            return CONTROL

        try:
            label = ''
            _treatment = CONTROL
            _change_number = -1

            # Fetching Split definition
            split = self._broker.fetch_feature(feature)

            if split is None:
                self._logger.warning('Unknown or invalid feature: %s', feature)
                label = Label.SPLIT_NOT_FOUND
                _treatment = CONTROL
            else:
                _change_number = split.change_number
                if split.killed:
                    label = Label.KILLED
                    _treatment = split.default_treatment
                else:
                    treatment, label = self._get_treatment_for_split(
                        split, matching_key, bucketing_key, attributes)
                    if treatment is None:
                        label = Label.NO_CONDITION_MATCHED
                        _treatment = split.default_treatment
                    else:
                        _treatment = treatment

            impression = self._build_impression(matching_key, feature,
                                                _treatment, label,
                                                _change_number, bucketing_key,
                                                start)
            self._record_stats(impression, start, SDK_GET_TREATMENT)

            self._send_impression_to_listener(impression, attributes)

            return _treatment
        except Exception:  # pylint: disable=broad-except
            self._logger.exception(
                'Exception caught getting treatment for feature')

            try:
                impression = self._build_impression(
                    matching_key, feature, CONTROL, Label.EXCEPTION,
                    self._broker.get_change_number(), bucketing_key, start)
                self._record_stats(impression, start, SDK_GET_TREATMENT)

                self._send_impression_to_listener(impression, attributes)
            except Exception:  # pylint: disable=broad-except
                self._logger.exception(
                    'Exception reporting impression into get_treatment exception block'
                )
            return CONTROL

    def get_treatments(self, key, features, attributes=None):
        """
        Get the treatments for a list of features considering a key, with an optional dictionary of
        attributes. This method never raises an exception. If there's a problem, the appropriate
        log message will be generated and the method will return the CONTROL treatment.
        :param key: The key for which to get the treatment
        :type key: str
        :param features: Array of the names of the features for which to get the treatment
        :type feature: list
        :param attributes: An optional dictionary of attributes
        :type attributes: dict
        :return: Dictionary with the result of all the features provided
        :rtype: dict
        """
        if self._destroyed:
            self._logger.warning(
                "Client has already been destroyed, returning None")
            return None

        features = input_validator.validate_features_get_treatments(features)

        if features is None:
            return None

        return {
            feature: self.get_treatment(key, feature, attributes)
            for feature in features
        }

    def _build_impression(self, matching_key, feature_name, treatment, label,
                          change_number, bucketing_key, imp_time):
        """
        Build an impression.
        """
        if not self._labels_enabled:
            label = None

        return Impression(matching_key=matching_key,
                          feature_name=feature_name,
                          treatment=treatment,
                          label=label,
                          change_number=change_number,
                          bucketing_key=bucketing_key,
                          time=imp_time)

    def _record_stats(self, impression, start, operation):
        """
        Record impression and metrics.

        :param impression: Generated impression
        :type impression: Impression

        :param start: timestamp when get_treatment was called
        :type start: int

        :param operation: operation performed.
        :type operation: str
        """
        try:
            end = int(round(time.time() * 1000))
            self._broker.log_impression(impression)
            self._broker.log_operation_time(operation, end - start)
        except Exception:  # pylint: disable=broad-except
            self._logger.exception(
                'Exception caught recording impressions and metrics')

    def _get_treatment_for_split(self,
                                 split,
                                 matching_key,
                                 bucketing_key,
                                 attributes=None):
        """
        Evaluate the user submitted data againt a feature and return the resulting treatment.

        This method might raise exceptions and should never be used directly.
        :param split: The split for which to get the treatment
        :type split: Split

        :param key: The key for which to get the treatment
        :type key: str

        :param attributes: An optional dictionary of attributes
        :type attributes: dict

        :return: The treatment for the key and split
        :rtype: str
        """
        if bucketing_key is None:
            bucketing_key = matching_key

        matcher_client = MatcherClient(self._broker, self._splitter,
                                       self._logger)

        roll_out = False
        for condition in split.conditions:
            if (not roll_out
                    and condition.condition_type == ConditionType.ROLLOUT):
                if split.traffic_allocation < 100:
                    bucket = self._splitter.get_bucket(
                        bucketing_key, split.traffic_allocation_seed,
                        split.algo)
                    if bucket >= split.traffic_allocation:
                        return split.default_treatment, Label.NOT_IN_SPLIT
                roll_out = True

            condition_matches = condition.matcher.match(Key(
                matching_key, bucketing_key),
                                                        attributes=attributes,
                                                        client=matcher_client)

            if condition_matches:
                return self._splitter.get_treatment(
                    bucketing_key, split.seed, condition.partitions,
                    split.algo), condition.label

        # No condition matches
        return None, None

    def track(self, key, traffic_type, event_type, value=None):
        """
        Track an event.

        :param key: user key associated to the event
        :type key: str

        :param traffic_type: traffic type name
        :type traffic_type: str

        :param event_type: event type name
        :type event_type: str

        :param value: (Optional) value associated to the event
        :type value: Number

        :rtype: bool
        """
        key = input_validator.validate_track_key(key)
        event_type = input_validator.validate_event_type(event_type)
        traffic_type = input_validator.validate_traffic_type(traffic_type)
        value = input_validator.validate_value(value)

        if key is None or event_type is None or traffic_type is None or value is False:
            return False

        event = Event(key=key,
                      trafficTypeName=traffic_type,
                      eventTypeId=event_type,
                      value=value,
                      timestamp=int(time.time() * 1000))
        return self._broker.get_events_log().log_event(event)
Ejemplo n.º 11
0
class Client(object):
    def __init__(self, broker, labels_enabled=True):
        """Basic interface of a Client. Specific implementations need to override the
        get_split_fetcher method (and optionally the get_splitter method).
        """
        self._logger = logging.getLogger(self.__class__.__name__)
        self._splitter = Splitter()
        self._broker = broker
        self._labels_enabled = labels_enabled
        self._destroyed = False

    @staticmethod
    def _get_keys(key):
        """
        """
        if isinstance(key, Key):
            matching_key = key.matching_key
            bucketing_key = key.bucketing_key
        else:
            matching_key = str(key)
            bucketing_key = None
        return matching_key, bucketing_key

    def destroy(self):
        """
        Disable the split-client and free all allocated resources.
        Only applicable when using in-memory operation mode.
        """
        self._destroyed = True
        self._broker.destroy()

    def get_treatment(self, key, feature, attributes=None):
        """
        Get the treatment for a feature and key, with an optional dictionary of attributes. This
        method never raises an exception. If there's a problem, the appropriate log message will
        be generated and the method will return the CONTROL treatment.
        :param key: The key for which to get the treatment
        :type key: str
        :param feature: The name of the feature for which to get the treatment
        :type feature: str
        :param attributes: An optional dictionary of attributes
        :type attributes: dict
        :return: The treatment for the key and feature
        :rtype: str
        """
        if self._destroyed:
            return CONTROL

        if key is None or feature is None:
            return CONTROL

        start = int(round(time.time() * 1000))

        matching_key, bucketing_key = self._get_keys(key)

        try:
            label = ''
            _treatment = CONTROL
            _change_number = -1

            # Fetching Split definition
            split = self._broker.fetch_feature(feature)

            if split is None:
                self._logger.warning('Unknown or invalid feature: %s', feature)
                label = Label.SPLIT_NOT_FOUND
                _treatment = CONTROL
            else:
                _change_number = split.change_number
                if split.killed:
                    label = Label.KILLED
                    _treatment = split.default_treatment
                else:
                    treatment, label = self._get_treatment_for_split(
                        split, matching_key, bucketing_key, attributes)
                    if treatment is None:
                        label = Label.NO_CONDITION_MATCHED
                        _treatment = split.default_treatment
                    else:
                        _treatment = treatment

            impression = self._build_impression(matching_key, feature,
                                                _treatment, label,
                                                _change_number, bucketing_key,
                                                start)
            self._record_stats(impression, start, SDK_GET_TREATMENT)
            return _treatment
        except:
            self._logger.exception(
                'Exception caught getting treatment for feature')

            try:
                impression = self._build_impression(
                    matching_key, feature, CONTROL, Label.EXCEPTION,
                    self._broker.get_change_number(), bucketing_key, start)
                self._record_stats(impression, start, SDK_GET_TREATMENT)
            except:
                self._logger.exception(
                    'Exception reporting impression into get_treatment exception block'
                )

            return CONTROL

    def _build_impression(self, matching_key, feature_name, treatment, label,
                          change_number, bucketing_key, time):

        if not self._labels_enabled:
            label = None

        return Impression(matching_key=matching_key,
                          feature_name=feature_name,
                          treatment=treatment,
                          label=label,
                          change_number=change_number,
                          bucketing_key=bucketing_key,
                          time=time)

    def _record_stats(self, impression, start, operation):
        try:
            end = int(round(time.time() * 1000))
            self._broker.log_impression(impression)
            self._broker.log_operation_time(operation, end - start)
        except:
            self._logger.exception(
                'Exception caught recording impressions and metrics')

    def _get_treatment_for_split(self,
                                 split,
                                 matching_key,
                                 bucketing_key,
                                 attributes=None):
        """
        Internal method to get the treatment for a given Split and optional attributes. This
        method might raise exceptions and should never be used directly.
        :param split: The split for which to get the treatment
        :type split: Split
        :param key: The key for which to get the treatment
        :type key: str
        :param attributes: An optional dictionary of attributes
        :type attributes: dict
        :return: The treatment for the key and split
        :rtype: str
        """
        if bucketing_key is None:
            bucketing_key = matching_key

        matcher_client = MatcherClient(self._broker, self._splitter,
                                       self._logger)

        roll_out = False
        for condition in split.conditions:
            if (not roll_out
                    and condition.condition_type == ConditionType.ROLLOUT):
                if split.traffic_allocation < 100:
                    bucket = self._splitter.get_bucket(
                        bucketing_key, split.traffic_allocation_seed,
                        split.algo)
                    if bucket >= split.traffic_allocation:
                        return split.default_treatment, Label.NOT_IN_SPLIT
                roll_out = True

            condition_matches = condition.matcher.match(Key(
                matching_key, bucketing_key),
                                                        attributes=attributes,
                                                        client=matcher_client)

            if condition_matches:
                return self._splitter.get_treatment(
                    bucketing_key, split.seed, condition.partitions,
                    split.algo), condition.label

        # No condition matches
        return None, None

    def track(self, key, traffic_type, event_type, value=None):
        """
        Track an event
        """
        e = Event(key=key,
                  trafficTypeName=traffic_type,
                  eventTypeId=event_type,
                  value=value,
                  timestamp=int(time.time() * 1000))
        return self._broker.get_events_log().log_event(e)