Skip to content

Commit cdb9387

Browse files
authored
Merge pull request #140 from splitio/feature/input-validation-v2-split-names
Feature/input validation v2 split names
2 parents 538fd29 + 4560dd6 commit cdb9387

File tree

10 files changed

+651
-51
lines changed

10 files changed

+651
-51
lines changed

splitio/client/client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@ def get_treatment_with_config(self, key, feature, attributes=None):
109109
start = int(round(time.time() * 1000))
110110

111111
matching_key, bucketing_key = input_validator.validate_key(key)
112-
feature = input_validator.validate_feature_name(feature)
112+
feature = input_validator.validate_feature_name(
113+
feature,
114+
self.ready,
115+
self._factory._get_storage('splits') #pylint: disable=protected-access
116+
)
113117

114118
if (matching_key is None and bucketing_key is None) \
115119
or feature is None \
@@ -204,12 +208,16 @@ def get_treatments_with_config(self, key, features, attributes=None):
204208
if input_validator.validate_attributes(attributes) is False:
205209
return input_validator.generate_control_treatments(features)
206210

207-
features = input_validator.validate_features_get_treatments(features)
211+
features, missing = input_validator.validate_features_get_treatments(
212+
features,
213+
self.ready,
214+
self._factory._get_storage('splits') # pylint: disable=protected-access
215+
)
208216
if features is None:
209217
return {}
210218

211219
bulk_impressions = []
212-
treatments = {}
220+
treatments = {name: (CONTROL, None) for name in missing}
213221

214222
for feature in features:
215223
try:

splitio/client/input_validator.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def validate_key(key):
252252
return matching_key_result, bucketing_key_result
253253

254254

255-
def validate_feature_name(feature_name):
255+
def validate_feature_name(feature_name, should_validate_existance, split_storage):
256256
"""
257257
Check if feature_name is valid for get_treatment.
258258
@@ -266,6 +266,16 @@ def validate_feature_name(feature_name):
266266
(not _check_is_string(feature_name, 'feature_name', operation)) or \
267267
(not _check_string_not_empty(feature_name, 'feature_name', operation)):
268268
return None
269+
270+
if should_validate_existance and split_storage.get(feature_name) is None:
271+
_LOGGER.error(
272+
"%s: you passed \"%s\" that does not exist in this environment, "
273+
"please double check what Splits exist in the web console.",
274+
operation,
275+
feature_name
276+
)
277+
return None
278+
269279
return _remove_empty_spaces(feature_name, operation)
270280

271281

@@ -355,7 +365,7 @@ def validate_value(value):
355365
return value
356366

357367

358-
def validate_manager_feature_name(feature_name):
368+
def validate_manager_feature_name(feature_name, should_validate_existance, split_storage):
359369
"""
360370
Check if feature_name is valid for track.
361371
@@ -368,25 +378,34 @@ def validate_manager_feature_name(feature_name):
368378
(not _check_is_string(feature_name, 'feature_name', 'split')) or \
369379
(not _check_string_not_empty(feature_name, 'feature_name', 'split')):
370380
return None
381+
382+
if should_validate_existance and split_storage.get(feature_name) is None:
383+
_LOGGER.error(
384+
"split: you passed \"%s\" that does not exist in this environment, "
385+
"please double check what Splits exist in the web console.",
386+
feature_name
387+
)
388+
return None
389+
371390
return feature_name
372391

373392

374-
def validate_features_get_treatments(features): #pylint: disable=invalid-name
393+
def validate_features_get_treatments(features, should_validate_existance=False, split_storage=None): #pylint: disable=invalid-name
375394
"""
376395
Check if features is valid for get_treatments.
377396
378397
:param features: array of features
379398
:type features: list
380399
:return: filtered_features
381-
:rtype: list|None
400+
:rtype: tuple
382401
"""
383402
operation = _get_first_split_sdk_call()
384403
if features is None or not isinstance(features, list):
385404
_LOGGER.error("%s: feature_names must be a non-empty array.", operation)
386-
return None
405+
return None, None
387406
if not features:
388407
_LOGGER.error("%s: feature_names must be a non-empty array.", operation)
389-
return []
408+
return None, None
390409
filtered_features = set(
391410
_remove_empty_spaces(feature, operation) for feature in features
392411
if feature is not None and
@@ -395,8 +414,20 @@ def validate_features_get_treatments(features): #pylint: disable=invalid-name
395414
)
396415
if not filtered_features:
397416
_LOGGER.error("%s: feature_names must be a non-empty array.", operation)
398-
return None
399-
return filtered_features
417+
return None, None
418+
419+
if not should_validate_existance:
420+
return filtered_features, []
421+
422+
valid_missing_features = set(f for f in filtered_features if split_storage.get(f) is None)
423+
for missing_feature in valid_missing_features:
424+
_LOGGER.error(
425+
"%s: you passed \"%s\" that does not exist in this environment, "
426+
"please double check what Splits exist in the web console.",
427+
operation,
428+
missing_feature
429+
)
430+
return filtered_features - valid_missing_features, valid_missing_features
400431

401432

402433
def generate_control_treatments(features):
@@ -408,7 +439,7 @@ def generate_control_treatments(features):
408439
:return: dict
409440
:rtype: dict|None
410441
"""
411-
return {feature: (CONTROL, None) for feature in validate_features_get_treatments(features)}
442+
return {feature: (CONTROL, None) for feature in validate_features_get_treatments(features)[0]}
412443

413444

414445
def validate_attributes(attributes):

splitio/client/manager.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def __init__(self, factory):
1818
"""
1919
self._logger = logging.getLogger(self.__class__.__name__)
2020
self._factory = factory
21-
self._storage = factory._get_storage('splits')
21+
self._storage = factory._get_storage('splits') #pylint: disable=protected-access
2222

2323
def split_names(self):
2424
"""
@@ -60,7 +60,12 @@ def split(self, feature_name):
6060
self._logger.error("Client has already been destroyed - no calls possible.")
6161
return []
6262

63-
feature_name = input_validator.validate_manager_feature_name(feature_name)
63+
feature_name = input_validator.validate_manager_feature_name(
64+
feature_name,
65+
self._factory.ready,
66+
self._storage
67+
)
68+
6469
if feature_name is None:
6570
return None
6671

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Caching trait module."""
2+
3+
import threading
4+
import time
5+
from functools import update_wrapper
6+
7+
import six
8+
9+
10+
DEFAULT_MAX_AGE = 5
11+
DEFAULT_MAX_SIZE = 100
12+
13+
14+
class LocalMemoryCache(object): #pylint: disable=too-many-instance-attributes
15+
"""
16+
Key/Value local memory cache. with expiration & LRU eviction.
17+
18+
LRU double-linked-list format:
19+
20+
{
21+
'key1'---------------------------------------------------------------
22+
'key2'------------------------------------ |
23+
'key3'------------ | |
24+
} | | |
25+
V V V
26+
|| MRU || -previous-> || X || ... -previous-> || LRU || -previous-> None
27+
None <---next--- || node || <---next--- || node || ... <---next--- || node ||
28+
"""
29+
30+
class _Node(object): #pylint: disable=too-few-public-methods
31+
"""Links to previous an next items in the circular list."""
32+
33+
def __init__(self, key, value, last_update, previous_element, next_element): #pylint: disable=too-many-arguments
34+
"""Class constructor."""
35+
self.key = key # we also keep the key for O(1) access when removing the LRU.
36+
self.value = value
37+
self.last_update = last_update
38+
self.previous = previous_element
39+
self.next = next_element
40+
41+
def __str__(self):
42+
"""Return string representation."""
43+
return '(%s, %s)' % (self.key, self.value)
44+
45+
def __init__(
46+
self,
47+
key_func,
48+
user_func,
49+
max_age_seconds=DEFAULT_MAX_AGE,
50+
max_size=DEFAULT_MAX_SIZE
51+
):
52+
"""Class constructor."""
53+
self._data = {}
54+
self._lock = threading.RLock()
55+
self._max_age_seconds = max_age_seconds
56+
self._max_size = max_size
57+
self._lru = None
58+
self._mru = None
59+
self._key_func = key_func
60+
self._user_func = user_func
61+
62+
def get(self, *args, **kwargs):
63+
"""
64+
Fetch an item from the cache. If it's a miss, call user function to refill.
65+
66+
:param args: User supplied positional arguments
67+
:type args: list
68+
:param kwargs: User supplied keyword arguments
69+
:type kwargs: dict
70+
71+
:return: Cached/Fetched object
72+
:rtype: object
73+
"""
74+
with self._lock:
75+
key = self._key_func(*args, **kwargs)
76+
node = self._data.get(key)
77+
if node is not None:
78+
if self._is_expired(node):
79+
node.value = self._user_func(*args, **kwargs)
80+
node.last_update = time.time()
81+
else:
82+
value = self._user_func(*args, **kwargs)
83+
node = LocalMemoryCache._Node(key, value, time.time(), None, None)
84+
node = self._bubble_up(node)
85+
self._data[key] = node
86+
self._rollover()
87+
return node.value
88+
89+
def remove_expired(self):
90+
"""Remove expired elements."""
91+
with self._lock:
92+
self._data = {
93+
key: value for (key, value) in six.iteritems(self._data)
94+
if not self._is_expired(value)
95+
}
96+
97+
def clear(self):
98+
"""Clear the cache."""
99+
self._data = {}
100+
self._lru = None
101+
self._mru = None
102+
103+
def _is_expired(self, node):
104+
"""Return whether the data held by the node is expired."""
105+
return time.time() - self._max_age_seconds > node.last_update
106+
107+
def _bubble_up(self, node):
108+
"""Send node to the top of the list (mark it as the MRU)."""
109+
if node is None:
110+
return None
111+
112+
if node.previous is not None:
113+
node.previous.next = node.next
114+
115+
if node.next is not None:
116+
node.next.previous = node.previous
117+
118+
if self._lru == node:
119+
if node.next is not None: #only update lru pointer if there are more than 1 elements.
120+
self._lru = node.next
121+
122+
if not self._data:
123+
# if there are no items, set the LRU to this node
124+
self._lru = node
125+
else:
126+
# if there is at least one item, update the MRU chain
127+
self._mru.next = node
128+
129+
node.next = None
130+
node.previous = self._mru
131+
self._mru = node
132+
return node
133+
134+
def _rollover(self):
135+
"""Check we're within the size limit. Otherwise drop the LRU."""
136+
if len(self._data) > self._max_size:
137+
next_item = self._lru.next
138+
del self._data[self._lru.key]
139+
self._lru = next_item
140+
141+
def __str__(self):
142+
"""User friendly representation of cache."""
143+
nodes = []
144+
node = self._mru
145+
while node is not None:
146+
nodes.append('<%s: %s> -->' % (node.key, node.value))
147+
node = node.previous
148+
return '<MRU>\n' + '\n'.join(nodes) + '\n<LRU>'
149+
150+
151+
def decorate(key_func, max_age_seconds=DEFAULT_MAX_AGE, max_size=DEFAULT_MAX_SIZE):
152+
"""
153+
Decorate a function or method to cache results up to `max_age_seconds`.
154+
155+
:param key_func: user specified function to execute over the arguments to determine the key.
156+
:type key_func: callable
157+
:param max_age_seconds: Maximum number of seconds during which the cached value is valid.
158+
:type max_age_seconds: int
159+
160+
:return: Decorating function wrapper.
161+
:rtype: callable
162+
"""
163+
if max_age_seconds < 0:
164+
raise TypeError('Max cache age cannot be a negative number.')
165+
166+
if max_size < 0:
167+
raise TypeError('Max cache size cannot be a negative number.')
168+
169+
if max_age_seconds == 0 or max_size == 0:
170+
return lambda function: function # bypass cache overlay.
171+
172+
def _decorator(user_function):
173+
"""
174+
Decorate function to be used with `@` syntax.
175+
176+
:param user_function: Function to decorate with cacheable results
177+
:type user_function: callable
178+
179+
:return: A function that looks exactly the same but with cacheable results.
180+
:rtype: callable
181+
"""
182+
_cache = LocalMemoryCache(key_func, user_function, max_age_seconds, max_size)
183+
# The lambda below IS necessary, otherwise update_wrapper fails since the function
184+
# is an instance method and has no reference to the __module__ namespace.
185+
wrapper = lambda *args, **kwargs: _cache.get(*args, **kwargs) #pylint: disable=unnecessary-lambda
186+
return update_wrapper(wrapper, user_function)
187+
188+
return _decorator

0 commit comments

Comments
 (0)