Skip to content
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
179 changes: 101 additions & 78 deletions growthbook/growthbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,36 +165,36 @@ def isIn(conditionValue, attributeValue) -> bool:
return attributeValue in conditionValue


def evalCondition(attributes: dict, condition: dict) -> bool:
def evalCondition(attributes: dict, condition: dict, savedGroups: dict = None) -> bool:
if "$or" in condition:
return evalOr(attributes, condition["$or"])
return evalOr(attributes, condition["$or"], savedGroups)
if "$nor" in condition:
return not evalOr(attributes, condition["$nor"])
return not evalOr(attributes, condition["$nor"], savedGroups)
if "$and" in condition:
return evalAnd(attributes, condition["$and"])
return evalAnd(attributes, condition["$and"], savedGroups)
if "$not" in condition:
return not evalCondition(attributes, condition["$not"])
return not evalCondition(attributes, condition["$not"], savedGroups)

for key, value in condition.items():
if not evalConditionValue(value, getPath(attributes, key)):
if not evalConditionValue(value, getPath(attributes, key), savedGroups):
return False

return True


def evalOr(attributes, conditions) -> bool:
def evalOr(attributes, conditions, savedGroups) -> bool:
if len(conditions) == 0:
return True

for condition in conditions:
if evalCondition(attributes, condition):
if evalCondition(attributes, condition, savedGroups):
return True
return False


def evalAnd(attributes, conditions) -> bool:
def evalAnd(attributes, conditions, savedGroups) -> bool:
for condition in conditions:
if not evalCondition(attributes, condition):
if not evalCondition(attributes, condition, savedGroups):
return False
return True

Expand Down Expand Up @@ -234,25 +234,25 @@ def getPath(attributes, path):
return current


def evalConditionValue(conditionValue, attributeValue) -> bool:
def evalConditionValue(conditionValue, attributeValue, savedGroups) -> bool:
if type(conditionValue) is dict and isOperatorObject(conditionValue):
for key, value in conditionValue.items():
if not evalOperatorCondition(key, attributeValue, value):
if not evalOperatorCondition(key, attributeValue, value, savedGroups):
return False
return True
return conditionValue == attributeValue


def elemMatch(condition, attributeValue) -> bool:
def elemMatch(condition, attributeValue, savedGroups) -> bool:
if not type(attributeValue) is list:
return False

for item in attributeValue:
if isOperatorObject(condition):
if evalConditionValue(condition, item):
if evalConditionValue(condition, item, savedGroups):
return True
else:
if evalCondition(item, condition):
if evalCondition(item, condition, savedGroups):
return True

return False
Expand All @@ -278,7 +278,7 @@ def compare(val1, val2) -> int:
return 0


def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups) -> bool:
if operator == "$eq":
try:
return compare(attributeValue, conditionValue) == 0
Expand Down Expand Up @@ -321,6 +321,18 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
return paddedVersionString(attributeValue) > paddedVersionString(conditionValue)
elif operator == "$vgte":
return paddedVersionString(attributeValue) >= paddedVersionString(conditionValue)
elif operator == "$inGroup":
if not type(conditionValue) is str:
return False
if not conditionValue in savedGroups:
return False
return isIn(savedGroups[conditionValue] or [], attributeValue)
elif operator == "$notInGroup":
if not type(conditionValue) is str:
return False
if not conditionValue in savedGroups:
return True
return not isIn(savedGroups[conditionValue] or [], attributeValue)
elif operator == "$regex":
try:
r = re.compile(conditionValue)
Expand All @@ -336,18 +348,18 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
return False
return not isIn(conditionValue, attributeValue)
elif operator == "$elemMatch":
return elemMatch(conditionValue, attributeValue)
return elemMatch(conditionValue, attributeValue, savedGroups)
elif operator == "$size":
if not (type(attributeValue) is list):
return False
return evalConditionValue(conditionValue, len(attributeValue))
return evalConditionValue(conditionValue, len(attributeValue), savedGroups)
elif operator == "$all":
if not (type(attributeValue) is list):
return False
for cond in conditionValue:
passing = False
for attr in attributeValue:
if evalConditionValue(cond, attr):
if evalConditionValue(cond, attr, savedGroups):
passing = True
if not passing:
return False
Expand All @@ -359,7 +371,7 @@ def evalOperatorCondition(operator, attributeValue, conditionValue) -> bool:
elif operator == "$type":
return getType(attributeValue) == conditionValue
elif operator == "$not":
return not evalConditionValue(conditionValue, attributeValue)
return not evalConditionValue(conditionValue, attributeValue, savedGroups)
return False


Expand Down Expand Up @@ -947,6 +959,9 @@ def set_cache(self, cache: AbstractFeatureCache) -> None:
def clear_cache(self):
self.cache.clear()

def save_in_cache(self, key: str, res, ttl: int = 60):
self.cache.set(key, res, ttl)

# Loads features with an in-memory cache in front
def load_features(
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 60
Expand Down Expand Up @@ -1011,31 +1026,49 @@ async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optio
except Exception as e:
logger.warning("Failed to decode feature JSON from GrowthBook API: %s", e)
return None

# Fetch features from the GrowthBook API
def _fetch_features(
self, api_host: str, client_key: str, decryption_key: str = ""
) -> Optional[Dict]:
decoded = self._fetch_and_decode(api_host, client_key)
if not decoded:
return None

if "encryptedFeatures" in decoded:

def decrypt_response(self, data, decryption_key: str):
if "encryptedFeatures" in data:
if not decryption_key:
raise ValueError("Must specify decryption_key")
try:
decrypted = decrypt(decoded["encryptedFeatures"], decryption_key)
return json.loads(decrypted)
decryptedFeatures = decrypt(data["encryptedFeatures"], decryption_key)
data['features'] = json.loads(decryptedFeatures)
del data['encryptedFeatures']
except Exception:
logger.warning(
"Failed to decrypt features from GrowthBook API response"
)
return None
elif "features" in decoded:
return decoded["features"]
else:
elif "features" not in data:
logger.warning("GrowthBook API response missing features")

if "encryptedSavedGroups" in data:
if not decryption_key:
raise ValueError("Must specify decryption_key")
try:
decryptedFeatures = decrypt(data["encryptedSavedGroups"], decryption_key)
data['savedGroups'] = json.loads(decryptedFeatures)
del data['encryptedSavedGroups']
return data
except Exception:
logger.warning(
"Failed to decrypt saved groups from GrowthBook API response"
)

return data

# Fetch features from the GrowthBook API
def _fetch_features(
self, api_host: str, client_key: str, decryption_key: str = ""
) -> Optional[Dict]:
decoded = self._fetch_and_decode(api_host, client_key)
if not decoded:
return None

data = self.decrypt_response(decoded, decryption_key)

return data

async def _fetch_features_async(
self, api_host: str, client_key: str, decryption_key: str = ""
Expand All @@ -1044,22 +1077,9 @@ async def _fetch_features_async(
if not decoded:
return None

if "encryptedFeatures" in decoded:
if not decryption_key:
raise ValueError("Must specify decryption_key")
try:
decrypted = decrypt(decoded["encryptedFeatures"], decryption_key)
return json.loads(decrypted)
except Exception:
logger.warning(
"Failed to decrypt features from GrowthBook API response"
)
return None
elif "features" in decoded:
return decoded["features"]
else:
logger.warning("GrowthBook API response missing features")
return None
data = self.decrypt_response(decoded, decryption_key)

return data


def startAutoRefresh(self, api_host, client_key, cb):
Expand Down Expand Up @@ -1094,6 +1114,7 @@ def __init__(
forced_variations: dict = {},
sticky_bucket_service: AbstractStickyBucketService = None,
sticky_bucket_identifier_attributes: List[str] = None,
savedGroups: dict = {},
streaming: bool = False,
# Deprecated args
trackingCallback=None,
Expand All @@ -1107,6 +1128,7 @@ def __init__(
self._attributes = attributes
self._url = url
self._features: Dict[str, Feature] = {}
self._saved_groups = savedGroups
self._api_host = api_host
self._client_key = client_key
self._decryption_key = decryption_key
Expand Down Expand Up @@ -1143,11 +1165,14 @@ def load_features(self) -> None:
if not self._client_key:
raise ValueError("Must specify `client_key` to refresh features")

features = feature_repo.load_features(
response = feature_repo.load_features(
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
)
if features is not None:
self.setFeatures(features)
if response is not None and "features" in response.keys():
self.setFeatures(response["features"])

if response is not None and "savedGroups" in response:
self._saved_groups = response["savedGroups"]

async def load_features_async(self) -> None:
if not self._client_key:
Expand All @@ -1156,37 +1181,35 @@ async def load_features_async(self) -> None:
features = await feature_repo.load_features_async(
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
)

if features is not None:
self.setFeatures(features)
if "features" in features:
self.setFeatures(features["features"])
if "savedGroups" in features:
self._saved_groups = features["savedGroups"]
feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)

def features_event_handler(self, features):
def _features_event_handler(self, features):
decoded = json.loads(features)
if not decoded:
return None

if "encryptedFeatures" in decoded:
if not self._decryption_key:
raise ValueError("Must specify decryption_key")
try:
decrypted = decrypt(decoded["encryptedFeatures"], self._decryption_key)
return json.loads(decrypted)
except Exception:
logger.warning(
"Failed to decrypt features from GrowthBook API response"
)
return None
elif "features" in decoded:
self.set_features(decoded["features"])
else:
logger.warning("GrowthBook API response missing features")

data = feature_repo.decrypt_response(decoded, self._decryption_key)

if data is not None:
if "features" in data:
self.setFeatures(data["features"])
if "savedGroups" in data:
self._saved_groups = data["savedGroups"]
feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)

def dispatch_sse_event(self, event_data):
def _dispatch_sse_event(self, event_data):
event_type = event_data['type']
data = event_data['data']
if event_type == 'features-updated':
self.load_features()
elif event_type == 'features':
self.features_event_handler(data)
self._features_event_handler(data)


def startAutoRefresh(self):
Expand All @@ -1196,7 +1219,7 @@ def startAutoRefresh(self):
feature_repo.startAutoRefresh(
api_host=self._api_host,
client_key=self._client_key,
cb=self.dispatch_sse_event
cb=self._dispatch_sse_event
)

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

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

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

# 8. Exclude if condition is false
if experiment.condition and not evalCondition(
self._attributes, experiment.condition
self._attributes, experiment.condition, self._saved_groups
):
logger.debug(
"Skip experiment %s because user failed the condition", experiment.key
Expand Down
Loading
Loading