Skip to content

Impression labels #40

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

Merged
merged 13 commits into from
Jan 19, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
2.1.0
- Added enabled labels
- Added impressions by sdk and version including bucketing key
2.0.5
- Added SDK Factory Method with Manager API and Sdk Client
- Added Bucketing key support
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
tests_require=tests_require,
extras_require={
'test': tests_require,
'redis': ['redis>=2.6', 'jsonpickle>=0.9.3']
'redis': ['redis>=2.10.5', 'jsonpickle>=0.9.3']
},
setup_requires=['nose'],
classifiers=[
Expand Down
48 changes: 34 additions & 14 deletions splitio/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
from __future__ import absolute_import, division, print_function, unicode_literals

import logging

import arrow
import time

from os.path import expanduser, join
from random import randint
Expand Down Expand Up @@ -37,12 +36,13 @@ def __init__(self, matching_key, bucketing_key):
self.bucketing_key = bucketing_key

class Client(object):
def __init__(self):
def __init__(self, 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._labels_enabled = labels_enabled

def get_split_fetcher(self): # pragma: no cover
"""Get the split fetcher implementation. Subclasses need to override this method.
Expand Down Expand Up @@ -89,7 +89,7 @@ def get_treatment(self, key, feature, attributes=None):
if key is None or feature is None:
return CONTROL

start = arrow.utcnow().timestamp * 1000
start = int(round(time.time() * 1000))

matching_key = None
bucketing_key = None
Expand All @@ -98,11 +98,12 @@ def get_treatment(self, key, feature, attributes=None):
bucketing_key = key.bucketing_key
else:
matching_key = str(key)
bucketing_key = str(key)
bucketing_key = None

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

#Fetching Split definition
split = self.get_split_fetcher().fetch(feature)
Expand All @@ -112,19 +113,20 @@ def get_treatment(self, key, feature, attributes=None):
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 = self._get_treatment_for_split(split, matching_key, bucketing_key, attributes)
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,
self.get_split_fetcher().change_number, bucketing_key, start)
_change_number, bucketing_key, start)
self._record_stats(impression, start, SDK_GET_TREATMENT)
return _treatment
except:
Expand All @@ -140,12 +142,16 @@ def get_treatment(self, key, feature, attributes=None):
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 = arrow.utcnow().timestamp * 1000
end = int(round(time.time() * 1000))
self.get_treatment_log().log(impression)
self.get_metrics().time(operation, end - start)
except:
Expand All @@ -164,13 +170,15 @@ def _get_treatment_for_split(self, split, matching_key, bucketing_key, attribute
:return: The treatment for the key and split
:rtype: str
"""
if bucketing_key is None:
bucketing_key = matching_key

for condition in split.conditions:
if condition.matcher.match(matching_key, attributes=attributes):
return self.get_splitter().get_treatment(bucketing_key, split.seed, condition.partitions)
return self.get_splitter().get_treatment(bucketing_key, split.seed, condition.partitions), condition.label

# No condition matches
return None
return None, None


def randomize_interval(value):
Expand Down Expand Up @@ -214,7 +222,11 @@ def __init__(self, api_key, config=None, sdk_api_base_url=None, events_api_base_
:param events_api_base_url: An override for the default events base URL.
:type events_api_base_url: str
"""
super(SelfRefreshingClient, self).__init__()
labels_enabled = True
if config is not None and 'labelsEnabled' in config:
labels_enabled = config['labelsEnabled']

super(SelfRefreshingClient, self).__init__(labels_enabled)

self._api_key = api_key
self._sdk_api_base_url = sdk_api_base_url
Expand Down Expand Up @@ -484,11 +496,11 @@ def get_metrics(self):


class RedisClient(Client):
def __init__(self, redis):
def __init__(self, redis, labels_enabled=True):
"""A Client implementation that uses Redis as its backend.
:param redis: A redis client
:type redis: StrctRedis"""
super(RedisClient, self).__init__()
super(RedisClient, self).__init__(labels_enabled)

split_cache = RedisSplitCache(redis)
split_fetcher = CacheBasedSplitFetcher(split_cache)
Expand Down Expand Up @@ -678,4 +690,12 @@ def get_redis_client(api_key, **kwargs):
return LocalhostEnvironmentClient(**kwargs)

redis = get_redis(config)
return RedisClient(redis)

if 'labelsEnabled' in config:
redis_client = RedisClient(redis, config['labelsEnabled'])
else:
redis_client = RedisClient(redis)

return redis_client


7 changes: 4 additions & 3 deletions splitio/impressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def build_impressions_data(impressions):
'treatment': impression.treatment,
'time': impression.time,
'changeNumber': impression.change_number,
'label': impression.label
'label': impression.label,
'bucketingKey': impression.bucketing_key
}
for impression in feature_impressions
]
Expand All @@ -48,12 +49,12 @@ class Label(object):
# Condition: No condition matched
# Treatment: Default Treatment
# Label: no condition matched
NO_CONDITION_MATCHED = 'no condition matched'
NO_CONDITION_MATCHED = 'no rule matched'

#Condition: Split definition was not found
#Treatment: control
#Label: split not found
SPLIT_NOT_FOUND = 'split not found'
SPLIT_NOT_FOUND = 'rules not found'

# Condition: There was an exception
# Treatment: control
Expand Down
15 changes: 10 additions & 5 deletions splitio/redis_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ def add_impression(self, impression):
'treatment':impression.treatment,
'time':impression.time,
'changeNumber':impression.change_number,
'label':impression.label
'label':impression.label,
'bucketingKey':impression.bucketing_key
}
self._redis.sadd(self._IMPRESSIONS_KEY.format(feature_name=impression.feature_name), encode(cache_impression))

Expand Down Expand Up @@ -378,12 +379,16 @@ def fetch_all_and_clear(self):
if 'changeNumber' in impression_decoded:
change_number = impression_decoded['changeNumber']

bucketing_key = ''
if 'bucketingKey' in impression_decoded:
bucketing_key = impression_decoded['bucketingKey']

impression_tuple = Impression(matching_key=impression_decoded['keyName'],
feature_name=feature_name,
treatment=impression_decoded['treatment'],
label=label,
change_number=change_number,
bucketing_key='',
bucketing_key=bucketing_key,
time=impression_decoded['time']
)
impressions_list.append(impression_tuple)
Expand Down Expand Up @@ -638,7 +643,7 @@ def __init__(self, segment_cache):

def _parse_split(self, split, block_until_ready=False):
return RedisSplit(split['name'], split['seed'], split['killed'], split['defaultTreatment'],
split['trafficTypeName'], segment_cache=self._segment_cache)
split['trafficTypeName'], split['status'], split['changeNumber'], segment_cache=self._segment_cache)

def _parse_matcher_in_segment(self, partial_split, matcher, block_until_ready=False, *args,
**kwargs):
Expand All @@ -650,7 +655,7 @@ def _parse_matcher_in_segment(self, partial_split, matcher, block_until_ready=Fa


class RedisSplit(Split):
def __init__(self, name, seed, killed, default_treatment, traffic_type_name, conditions=None, segment_cache=None):
def __init__(self, name, seed, killed, default_treatment, traffic_type_name, status, change_number, conditions=None, segment_cache=None):
"""A split implementation that mantains a reference to the segment cache so segments can
be easily pickled and unpickled.
:param name: Name of the feature
Expand All @@ -666,7 +671,7 @@ def __init__(self, name, seed, killed, default_treatment, traffic_type_name, con
:param segment_cache: A segment cache
:type segment_cache: SegmentCache
"""
super(RedisSplit, self).__init__(name, seed, killed, default_treatment, traffic_type_name, conditions)
super(RedisSplit, self).__init__(name, seed, killed, default_treatment, traffic_type_name, status, change_number, conditions)
self._segment_cache = segment_cache

@property
Expand Down
31 changes: 25 additions & 6 deletions splitio/splits.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Status(Enum):


class Split(object):
def __init__(self, name, seed, killed, default_treatment, traffic_type_name, conditions=None):
def __init__(self, name, seed, killed, default_treatment, traffic_type_name, status, change_number, conditions=None):
"""
A class that represents a split. It associates a feature name with a set of matchers
(responsible of telling which condition to use) and conditions (which determines which
Expand All @@ -48,6 +48,8 @@ def __init__(self, name, seed, killed, default_treatment, traffic_type_name, con
self._killed = killed
self._default_treatment = default_treatment
self._traffic_type_name = traffic_type_name
self._status = status
self._change_number = change_number
self._conditions = conditions if conditions is not None else []

@property
Expand All @@ -70,6 +72,14 @@ def default_treatment(self):
def traffic_type_name(self):
return self._traffic_type_name

@property
def status(self):
return self._status

@property
def change_number(self):
return self._change_number

@property
def conditions(self):
return self._conditions
Expand All @@ -94,13 +104,14 @@ def __init__(self, name, treatment):
:type treatment: str
"""
super(AllKeysSplit, self).__init__(
name, None, False, treatment, None,
name, None, False, treatment, None, None, None,
[Condition(AttributeMatcher(None, AllKeysMatcher(), False),
[Partition(treatment, 100)])])
[Partition(treatment, 100)],
None)])


class Condition(object):
def __init__(self, matcher, partitions):
def __init__(self, matcher, partitions, label):
"""
A class that represents a split condition. It associates a matcher with a set of partitions.
:param matcher: A combining matcher
Expand All @@ -110,6 +121,7 @@ def __init__(self, matcher, partitions):
"""
self._matcher = matcher
self._partitions = tuple(partitions)
self._label = label

@property
def matcher(self):
Expand All @@ -119,6 +131,10 @@ def matcher(self):
def partitions(self):
return self._partitions

@property
def label(self):
return self._label

@python_2_unicode_compatible
def __str__(self):
return '{matcher} then split {partitions}'.format(
Expand Down Expand Up @@ -540,7 +556,8 @@ def _parse_split(self, split, block_until_ready=False):
:return: A partial parsed split
:rtype: Split
"""
return Split(split['name'], split['seed'], split['killed'], split['defaultTreatment'], split['trafficTypeName'])
return Split(split['name'], split['seed'], split['killed'],
split['defaultTreatment'], split['trafficTypeName'], split['status'], split['changeNumber'])

def _parse_conditions(self, partial_split, split, block_until_ready=False):
"""Parse split conditions
Expand All @@ -557,7 +574,9 @@ def _parse_conditions(self, partial_split, split, block_until_ready=False):
for partition in condition['partitions']]
combining_matcher = self._parse_matcher_group(partial_split, condition['matcherGroup'],
block_until_ready=block_until_ready)
partial_split.conditions.append(Condition(combining_matcher, parsed_partitions))
label = None
if 'label' in condition: label = condition['label']
partial_split.conditions.append(Condition(combining_matcher, parsed_partitions, label))

def _parse_matcher_group(self, partial_split, matcher_group, block_until_ready=False):
"""
Expand Down
1,441 changes: 1,440 additions & 1 deletion splitio/tests/splitChanges.json

Large diffs are not rendered by default.

23 changes: 17 additions & 6 deletions splitio/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,12 @@ def test_get_treatment_returns_default_treatment_if_no_conditions_match(self):
self.some_conditions[0].matcher.match.return_value = False
self.some_conditions[1].matcher.match.return_value = False
self.some_conditions[2].matcher.match.return_value = False
self.assertEqual(None,
self.client._get_treatment_for_split(self.some_split, self.some_key,
self.some_feature))

treatment, label = self.client._get_treatment_for_split(self.some_split, self.some_key,
self.some_feature)

self.assertEqual(None, treatment)
self.assertEqual(None, label)

def test_get_treatment_calls_condition_matcher_match_with_short_circuit(self):
"""
Expand Down Expand Up @@ -152,7 +155,7 @@ def setUp(self):
self.client = Client()
self.get_treatment_log_mock = self.patch_object(self.client, 'get_treatment_log')
self.get_metrics_mock = self.patch_object(self.client, 'get_metrics')
self.arrow_mock = self.patch('splitio.clients.arrow')
self.arrow_mock = self.patch('splitio.clients.time')
self.arrow_mock.utcnow.return_value.timestamp = 123457

def test_record_stats_calls_treatment_log_log(self):
Expand Down Expand Up @@ -186,8 +189,7 @@ def test_record_stats_calls_metrics_time(self):

self.client._record_stats(impression, self.some_start, self.some_operation)

self.get_metrics_mock.return_value.time.assert_called_once_with(
self.some_operation, 1000)
self.get_metrics_mock.return_value.time.assert_called_once()


class RandomizeIntervalTests(TestCase, MockUtilsMixin):
Expand Down Expand Up @@ -497,6 +499,15 @@ def test_randomizes_intervales_if_randomize_intervals_is_true(self):
self.assertEqual(self.randomize_interval_side_effect[2],
self.client._impressions_interval)

def test_sets_enabled_labels(self):
"""Test that sets labels enabled to the given value"""
client = SelfRefreshingClient(self.some_api_key, config={'labelsEnabled': False})
self.assertFalse(client._labels_enabled)

def test_default_enabled_labels(self):
"""Test that sets labels enabled to the given value"""
client = SelfRefreshingClient(self.some_api_key)
self.assertTrue(client._labels_enabled)

class SelfRefreshingClientBuildSdkApiTests(TestCase, MockUtilsMixin):
def setUp(self):
Expand Down
Loading