Skip to content

Commit 313b1a0

Browse files
authored
Merge pull request #22 from growthbook/feature/saved-groups
Saved groups, $inGroup $notInGroup operators, versionCompare
2 parents 015ab58 + 8a2f32d commit 313b1a0

File tree

3 files changed

+3803
-810
lines changed

3 files changed

+3803
-810
lines changed

growthbook/growthbook.py

Lines changed: 101 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -165,36 +165,36 @@ def isIn(conditionValue, attributeValue) -> bool:
165165
return attributeValue in conditionValue
166166

167167

168-
def evalCondition(attributes: dict, condition: dict) -> bool:
168+
def evalCondition(attributes: dict, condition: dict, savedGroups: dict = None) -> bool:
169169
if "$or" in condition:
170-
return evalOr(attributes, condition["$or"])
170+
return evalOr(attributes, condition["$or"], savedGroups)
171171
if "$nor" in condition:
172-
return not evalOr(attributes, condition["$nor"])
172+
return not evalOr(attributes, condition["$nor"], savedGroups)
173173
if "$and" in condition:
174-
return evalAnd(attributes, condition["$and"])
174+
return evalAnd(attributes, condition["$and"], savedGroups)
175175
if "$not" in condition:
176-
return not evalCondition(attributes, condition["$not"])
176+
return not evalCondition(attributes, condition["$not"], savedGroups)
177177

178178
for key, value in condition.items():
179-
if not evalConditionValue(value, getPath(attributes, key)):
179+
if not evalConditionValue(value, getPath(attributes, key), savedGroups):
180180
return False
181181

182182
return True
183183

184184

185-
def evalOr(attributes, conditions) -> bool:
185+
def evalOr(attributes, conditions, savedGroups) -> bool:
186186
if len(conditions) == 0:
187187
return True
188188

189189
for condition in conditions:
190-
if evalCondition(attributes, condition):
190+
if evalCondition(attributes, condition, savedGroups):
191191
return True
192192
return False
193193

194194

195-
def evalAnd(attributes, conditions) -> bool:
195+
def evalAnd(attributes, conditions, savedGroups) -> bool:
196196
for condition in conditions:
197-
if not evalCondition(attributes, condition):
197+
if not evalCondition(attributes, condition, savedGroups):
198198
return False
199199
return True
200200

@@ -234,25 +234,25 @@ def getPath(attributes, path):
234234
return current
235235

236236

237-
def evalConditionValue(conditionValue, attributeValue) -> bool:
237+
def evalConditionValue(conditionValue, attributeValue, savedGroups) -> bool:
238238
if type(conditionValue) is dict and isOperatorObject(conditionValue):
239239
for key, value in conditionValue.items():
240-
if not evalOperatorCondition(key, attributeValue, value):
240+
if not evalOperatorCondition(key, attributeValue, value, savedGroups):
241241
return False
242242
return True
243243
return conditionValue == attributeValue
244244

245245

246-
def elemMatch(condition, attributeValue) -> bool:
246+
def elemMatch(condition, attributeValue, savedGroups) -> bool:
247247
if not type(attributeValue) is list:
248248
return False
249249

250250
for item in attributeValue:
251251
if isOperatorObject(condition):
252-
if evalConditionValue(condition, item):
252+
if evalConditionValue(condition, item, savedGroups):
253253
return True
254254
else:
255-
if evalCondition(item, condition):
255+
if evalCondition(item, condition, savedGroups):
256256
return True
257257

258258
return False
@@ -278,7 +278,7 @@ def compare(val1, val2) -> int:
278278
return 0
279279

280280

281-
def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
281+
def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups) -> bool:
282282
if operator == "$eq":
283283
try:
284284
return compare(attributeValue, conditionValue) == 0
@@ -321,6 +321,18 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
321321
return paddedVersionString(attributeValue) > paddedVersionString(conditionValue)
322322
elif operator == "$vgte":
323323
return paddedVersionString(attributeValue) >= paddedVersionString(conditionValue)
324+
elif operator == "$inGroup":
325+
if not type(conditionValue) is str:
326+
return False
327+
if not conditionValue in savedGroups:
328+
return False
329+
return isIn(savedGroups[conditionValue] or [], attributeValue)
330+
elif operator == "$notInGroup":
331+
if not type(conditionValue) is str:
332+
return False
333+
if not conditionValue in savedGroups:
334+
return True
335+
return not isIn(savedGroups[conditionValue] or [], attributeValue)
324336
elif operator == "$regex":
325337
try:
326338
r = re.compile(conditionValue)
@@ -336,18 +348,18 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
336348
return False
337349
return not isIn(conditionValue, attributeValue)
338350
elif operator == "$elemMatch":
339-
return elemMatch(conditionValue, attributeValue)
351+
return elemMatch(conditionValue, attributeValue, savedGroups)
340352
elif operator == "$size":
341353
if not (type(attributeValue) is list):
342354
return False
343-
return evalConditionValue(conditionValue, len(attributeValue))
355+
return evalConditionValue(conditionValue, len(attributeValue), savedGroups)
344356
elif operator == "$all":
345357
if not (type(attributeValue) is list):
346358
return False
347359
for cond in conditionValue:
348360
passing = False
349361
for attr in attributeValue:
350-
if evalConditionValue(cond, attr):
362+
if evalConditionValue(cond, attr, savedGroups):
351363
passing = True
352364
if not passing:
353365
return False
@@ -359,7 +371,7 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
359371
elif operator == "$type":
360372
return getType(attributeValue) == conditionValue
361373
elif operator == "$not":
362-
return not evalConditionValue(conditionValue, attributeValue)
374+
return not evalConditionValue(conditionValue, attributeValue, savedGroups)
363375
return False
364376

365377

@@ -947,6 +959,9 @@ def set_cache(self, cache: AbstractFeatureCache) -> None:
947959
def clear_cache(self):
948960
self.cache.clear()
949961

962+
def save_in_cache(self, key: str, res, ttl: int = 60):
963+
self.cache.set(key, res, ttl)
964+
950965
# Loads features with an in-memory cache in front
951966
def load_features(
952967
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 60
@@ -1011,31 +1026,49 @@ async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optio
10111026
except Exception as e:
10121027
logger.warning("Failed to decode feature JSON from GrowthBook API: %s", e)
10131028
return None
1014-
1015-
# Fetch features from the GrowthBook API
1016-
def _fetch_features(
1017-
self, api_host: str, client_key: str, decryption_key: str = ""
1018-
) -> Optional[Dict]:
1019-
decoded = self._fetch_and_decode(api_host, client_key)
1020-
if not decoded:
1021-
return None
1022-
1023-
if "encryptedFeatures" in decoded:
1029+
1030+
def decrypt_response(self, data, decryption_key: str):
1031+
if "encryptedFeatures" in data:
10241032
if not decryption_key:
10251033
raise ValueError("Must specify decryption_key")
10261034
try:
1027-
decrypted = decrypt(decoded["encryptedFeatures"], decryption_key)
1028-
return json.loads(decrypted)
1035+
decryptedFeatures = decrypt(data["encryptedFeatures"], decryption_key)
1036+
data['features'] = json.loads(decryptedFeatures)
1037+
del data['encryptedFeatures']
10291038
except Exception:
10301039
logger.warning(
10311040
"Failed to decrypt features from GrowthBook API response"
10321041
)
10331042
return None
1034-
elif "features" in decoded:
1035-
return decoded["features"]
1036-
else:
1043+
elif "features" not in data:
10371044
logger.warning("GrowthBook API response missing features")
1045+
1046+
if "encryptedSavedGroups" in data:
1047+
if not decryption_key:
1048+
raise ValueError("Must specify decryption_key")
1049+
try:
1050+
decryptedFeatures = decrypt(data["encryptedSavedGroups"], decryption_key)
1051+
data['savedGroups'] = json.loads(decryptedFeatures)
1052+
del data['encryptedSavedGroups']
1053+
return data
1054+
except Exception:
1055+
logger.warning(
1056+
"Failed to decrypt saved groups from GrowthBook API response"
1057+
)
1058+
1059+
return data
1060+
1061+
# Fetch features from the GrowthBook API
1062+
def _fetch_features(
1063+
self, api_host: str, client_key: str, decryption_key: str = ""
1064+
) -> Optional[Dict]:
1065+
decoded = self._fetch_and_decode(api_host, client_key)
1066+
if not decoded:
10381067
return None
1068+
1069+
data = self.decrypt_response(decoded, decryption_key)
1070+
1071+
return data
10391072

10401073
async def _fetch_features_async(
10411074
self, api_host: str, client_key: str, decryption_key: str = ""
@@ -1044,22 +1077,9 @@ async def _fetch_features_async(
10441077
if not decoded:
10451078
return None
10461079

1047-
if "encryptedFeatures" in decoded:
1048-
if not decryption_key:
1049-
raise ValueError("Must specify decryption_key")
1050-
try:
1051-
decrypted = decrypt(decoded["encryptedFeatures"], decryption_key)
1052-
return json.loads(decrypted)
1053-
except Exception:
1054-
logger.warning(
1055-
"Failed to decrypt features from GrowthBook API response"
1056-
)
1057-
return None
1058-
elif "features" in decoded:
1059-
return decoded["features"]
1060-
else:
1061-
logger.warning("GrowthBook API response missing features")
1062-
return None
1080+
data = self.decrypt_response(decoded, decryption_key)
1081+
1082+
return data
10631083

10641084

10651085
def startAutoRefresh(self, api_host, client_key, cb):
@@ -1094,6 +1114,7 @@ def __init__(
10941114
forced_variations: dict = {},
10951115
sticky_bucket_service: AbstractStickyBucketService = None,
10961116
sticky_bucket_identifier_attributes: List[str] = None,
1117+
savedGroups: dict = {},
10971118
streaming: bool = False,
10981119
# Deprecated args
10991120
trackingCallback=None,
@@ -1107,6 +1128,7 @@ def __init__(
11071128
self._attributes = attributes
11081129
self._url = url
11091130
self._features: Dict[str, Feature] = {}
1131+
self._saved_groups = savedGroups
11101132
self._api_host = api_host
11111133
self._client_key = client_key
11121134
self._decryption_key = decryption_key
@@ -1143,11 +1165,14 @@ def load_features(self) -> None:
11431165
if not self._client_key:
11441166
raise ValueError("Must specify `client_key` to refresh features")
11451167

1146-
features = feature_repo.load_features(
1168+
response = feature_repo.load_features(
11471169
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
11481170
)
1149-
if features is not None:
1150-
self.setFeatures(features)
1171+
if response is not None and "features" in response.keys():
1172+
self.setFeatures(response["features"])
1173+
1174+
if response is not None and "savedGroups" in response:
1175+
self._saved_groups = response["savedGroups"]
11511176

11521177
async def load_features_async(self) -> None:
11531178
if not self._client_key:
@@ -1156,37 +1181,35 @@ async def load_features_async(self) -> None:
11561181
features = await feature_repo.load_features_async(
11571182
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
11581183
)
1184+
11591185
if features is not None:
1160-
self.setFeatures(features)
1186+
if "features" in features:
1187+
self.setFeatures(features["features"])
1188+
if "savedGroups" in features:
1189+
self._saved_groups = features["savedGroups"]
1190+
feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)
11611191

1162-
def features_event_handler(self, features):
1192+
def _features_event_handler(self, features):
11631193
decoded = json.loads(features)
11641194
if not decoded:
11651195
return None
1166-
1167-
if "encryptedFeatures" in decoded:
1168-
if not self._decryption_key:
1169-
raise ValueError("Must specify decryption_key")
1170-
try:
1171-
decrypted = decrypt(decoded["encryptedFeatures"], self._decryption_key)
1172-
return json.loads(decrypted)
1173-
except Exception:
1174-
logger.warning(
1175-
"Failed to decrypt features from GrowthBook API response"
1176-
)
1177-
return None
1178-
elif "features" in decoded:
1179-
self.set_features(decoded["features"])
1180-
else:
1181-
logger.warning("GrowthBook API response missing features")
1196+
1197+
data = feature_repo.decrypt_response(decoded, self._decryption_key)
1198+
1199+
if data is not None:
1200+
if "features" in data:
1201+
self.setFeatures(data["features"])
1202+
if "savedGroups" in data:
1203+
self._saved_groups = data["savedGroups"]
1204+
feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)
11821205

1183-
def dispatch_sse_event(self, event_data):
1206+
def _dispatch_sse_event(self, event_data):
11841207
event_type = event_data['type']
11851208
data = event_data['data']
11861209
if event_type == 'features-updated':
11871210
self.load_features()
11881211
elif event_type == 'features':
1189-
self.features_event_handler(data)
1212+
self._features_event_handler(data)
11901213

11911214

11921215
def startAutoRefresh(self):
@@ -1196,7 +1219,7 @@ def startAutoRefresh(self):
11961219
feature_repo.startAutoRefresh(
11971220
api_host=self._api_host,
11981221
client_key=self._client_key,
1199-
cb=self.dispatch_sse_event
1222+
cb=self._dispatch_sse_event
12001223
)
12011224

12021225
def stopAutoRefresh(self):
@@ -1284,7 +1307,7 @@ def eval_prereqs(self, parentConditions: List[dict], stack: Set[str]) -> str:
12841307
if parentRes.source == "cyclicPrerequisite":
12851308
return "cyclic"
12861309

1287-
if not evalCondition({'value': parentRes.value}, parentCondition.get("condition", None)):
1310+
if not evalCondition({'value': parentRes.value}, parentCondition.get("condition", None), self._saved_groups):
12881311
if parentCondition.get("gate", False):
12891312
return "gate"
12901313
return "fail"
@@ -1320,7 +1343,7 @@ def _eval_feature(self, key: str, stack: Set[str]) -> FeatureResult:
13201343
continue
13211344

13221345
if rule.condition:
1323-
if not evalCondition(self._attributes, rule.condition):
1346+
if not evalCondition(self._attributes, rule.condition, self._saved_groups):
13241347
logger.debug(
13251348
"Skip rule because of failed condition, feature %s", key
13261349
)
@@ -1600,7 +1623,7 @@ def _run(self, experiment: Experiment, featureId: Optional[str] = None) -> Resul
16001623

16011624
# 8. Exclude if condition is false
16021625
if experiment.condition and not evalCondition(
1603-
self._attributes, experiment.condition
1626+
self._attributes, experiment.condition, self._saved_groups
16041627
):
16051628
logger.debug(
16061629
"Skip experiment %s because user failed the condition", experiment.key

0 commit comments

Comments
 (0)