Skip to content
Open
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
95 changes: 74 additions & 21 deletions growthbook/common_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python

import sys
from collections import OrderedDict
from token import OP
# Only require typing_extensions if using Python 3.7 or earlier
if sys.version_info >= (3, 8):
Expand All @@ -14,9 +15,9 @@
from abc import ABC, abstractmethod

class VariationMeta(TypedDict):
key: str
name: str
passthrough: bool
key: Optional[str]
name: Optional[str]
passthrough: Optional[bool]


class Filter(TypedDict):
Expand Down Expand Up @@ -143,12 +144,12 @@ def update(self, data: Dict[str, Any]) -> None:
class Result(object):
def __init__(
self,
variationId: int,
inExperiment: bool,
value: Any,
hashUsed: bool,
hashAttribute: str,
hashValue: str,
variationId: Optional[int],
inExperiment: Optional[bool],
value,
hashUsed: Optional[bool],
hashAttribute: Optional[str],
hashValue: Optional[str],
featureId: Optional[str],
meta: Optional[VariationMeta] = None,
bucket: Optional[float] = None,
Expand All @@ -164,17 +165,17 @@ def __init__(
self.bucket = bucket
self.stickyBucketUsed = stickyBucketUsed

self.key = str(variationId)
self.key = str(variationId) if variationId is not None else ""
self.name = ""
self.passthrough = False

if meta:
if "name" in meta:
self.name = meta["name"]
if "key" in meta:
self.key = meta["key"]
if "passthrough" in meta:
self.passthrough = meta["passthrough"]
if "name" in meta and meta["name"] is not None:
self.name = str(meta["name"])
if "key" in meta and meta["key"] is not None:
self.key = str(meta["key"])
if "passthrough" in meta and meta["passthrough"] is not None:
self.passthrough = bool(meta["passthrough"])

def to_dict(self) -> Dict[str, Any]:
obj: Dict[str, Any] = {
Expand All @@ -198,11 +199,30 @@ def to_dict(self) -> Dict[str, Any]:

return obj

@staticmethod
def from_dict(data: dict) -> "Result":
return Result(
variationId=data.get("variationId"),
inExperiment=data.get("inExperiment"),
value=data.get("value"),
hashUsed=data.get("hashUsed"),
hashAttribute=data.get("hashAttribute"),
hashValue=data.get("hashValue"),
featureId=data.get("featureId"),
bucket=data.get("bucket"),
stickyBucketUsed=data.get("stickyBucketUsed", False),
meta={
"name": data.get("name"),
"key": data.get("key"),
"passthrough": data.get("passthrough"),
}
)

class FeatureResult(object):
def __init__(
self,
value: Any,
source: str,
source: Optional[str] = None,
experiment: Optional[Experiment] = None,
experimentResult: Optional[Result] = None,
ruleId: Optional[str] = None,
Expand All @@ -218,7 +238,7 @@ def __init__(
def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {
"value": self.value,
"source": self.source,
"source": self.source or "",
"on": self.on,
"off": self.off,
"ruleId": self.ruleId or "",
Expand All @@ -230,6 +250,17 @@ def to_dict(self) -> Dict[str, Any]:

return data

@staticmethod
def from_dict(data: dict) -> "FeatureResult":
return FeatureResult(
value=data.get("value"),
source=data.get("source"),
experiment=Experiment(**data["experiment"]) if isinstance(data.get("experiment"), dict) else data.get(
"experiment"),
experimentResult=Result.from_dict(data["experimentResult"]) if isinstance(data.get("experimentResult"), dict) else data.get("experimentResult"),
ruleId=data.get("ruleId"),
)

class Feature(object):
def __init__(self, defaultValue: Any = None, rules: Optional[List[Any]] = None) -> None:
if rules is None:
Expand Down Expand Up @@ -263,6 +294,7 @@ def __init__(self, defaultValue: Any = None, rules: Optional[List[Any]] = None)
bucketVersion=rule.get("bucketVersion", None),
minBucketVersion=rule.get("minBucketVersion", None),
parentConditions=rule.get("parentConditions", None),
tracks=rule.get("tracks", None),
))

def to_dict(self) -> Dict[str, Any]:
Expand All @@ -271,6 +303,17 @@ def to_dict(self) -> Dict[str, Any]:
"rules": [rule.to_dict() for rule in self.rules],
}

@dataclass
class TrackData:
experiment: Experiment
result: Result

def to_dict(self) -> Dict[str, Any]:
return {
"experiment": self.experiment.to_dict() if hasattr(self.experiment, 'to_dict') else self.experiment,
"result": self.result.to_dict() if hasattr(self.result, 'to_dict') else self.result
}

class FeatureRule(object):
def __init__(
self,
Expand All @@ -296,6 +339,7 @@ def __init__(
bucketVersion: Optional[int] = None,
minBucketVersion: Optional[int] = None,
parentConditions: Optional[List[Dict[str, Any]]] = None,
tracks: List[TrackData] = None
) -> None:

if disableStickyBucketing:
Expand Down Expand Up @@ -323,6 +367,11 @@ def __init__(
self.bucketVersion = bucketVersion or 0
self.minBucketVersion = minBucketVersion or 0
self.parentConditions = parentConditions
self.tracks = []
if tracks:
for t in tracks:
if isinstance(t, TrackData):
self.tracks.append(t)

def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {}
Expand Down Expand Up @@ -370,6 +419,8 @@ def to_dict(self) -> Dict[str, Any]:
data["minBucketVersion"] = self.minBucketVersion
if self.parentConditions:
data["parentConditions"] = self.parentConditions
if self.tracks:
data["tracks"] = [track.to_dict() for track in self.tracks]

return data

Expand All @@ -396,7 +447,7 @@ def get_all_assignments(self, attributes: Dict[str, str]) -> Dict[str, Dict]:
return docs

@dataclass
class StackContext:
class StackContext:
id: Optional[str] = None
evaluated_features: Set[str] = field(default_factory=set)

Expand Down Expand Up @@ -424,14 +475,16 @@ class Options:
enabled: bool = True
qa_mode: bool = False
enable_dev_mode: bool = False
# forced_variations: Dict[str, Any] = field(default_factory=dict)
forced_variations: Dict[str, Any] = field(default_factory=dict)
refresh_strategy: Optional[FeatureRefreshStrategy] = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE
sticky_bucket_service: Optional[AbstractStickyBucketService] = None
sticky_bucket_identifier_attributes: Optional[List[str]] = None
on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
on_feature_usage: Optional[Callable[[str, 'FeatureResult', UserContext], None]] = None
tracking_plugins: Optional[List[Any]] = None

remote_eval: bool = False
global_attributes: Dict[str, Any] = field(default_factory=dict)
forced_features: Dict[str, Any] = field(default_factory=dict)

@dataclass
class GlobalContext:
Expand Down
31 changes: 19 additions & 12 deletions growthbook/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Callable, Optional, Any, Set, Tuple, List, Dict
from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta


logger = logging.getLogger("growthbook.core")

def evalCondition(attributes: Dict[str, Any], condition: Dict[str, Any], savedGroups: Optional[Dict[str, Any]] = None) -> bool:
Expand Down Expand Up @@ -299,7 +298,7 @@ def _isIncludedInRollout(

def _isFilteredOut(filters: List[Filter], eval_context: EvaluationContext) -> bool:
for filter in filters:
(_, hash_value) = _getHashValue(attr=filter.get("attribute", "id"), eval_context=eval_context)
(_, hash_value) = _getHashValue(attr=filter.get("attribute", "id"), eval_context=eval_context)
if hash_value == "":
return False

Expand Down Expand Up @@ -420,15 +419,15 @@ def eval_feature(

if evalContext is None:
raise ValueError("evalContext is required - eval_feature")

if key not in evalContext.global_ctx.features:
logger.warning("Unknown feature %s", key)
return FeatureResult(None, "unknownFeature")

if key in evalContext.stack.evaluated_features:
logger.warning("Cyclic prerequisite detected, stack: %s", evalContext.stack.evaluated_features)
return FeatureResult(None, "cyclicPrerequisite")

evalContext.stack.evaluated_features.add(key)

feature = evalContext.global_ctx.features[key]
Expand Down Expand Up @@ -479,6 +478,14 @@ def eval_feature(
)
continue

tracks = rule.tracks

if tracks and tracking_cb:
for track in tracks:
tracked_experiment = track.experiment
tracked_experiment_result = track.result
tracking_cb(tracked_experiment, tracked_experiment_result, evalContext.user)

logger.debug("Force value from rule, feature %s", key)
return FeatureResult(rule.force, "force", ruleId=rule.id)

Expand Down Expand Up @@ -540,7 +547,7 @@ def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -
parent_id = parentCondition.get("id")
if parent_id is None:
continue # Skip if no valid ID

parentRes = eval_feature(key=parent_id, evalContext=evalContext)

if parentRes.source == "cyclicPrerequisite":
Expand All @@ -549,7 +556,7 @@ def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -
parent_condition = parentCondition.get("condition")
if parent_condition is None:
continue # Skip if no valid condition

if not evalCondition({'value': parentRes.value}, parent_condition, evalContext.global_ctx.saved_groups):
if parentCondition.get("gate", False):
return "gate"
Expand All @@ -558,7 +565,7 @@ def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -

def _get_sticky_bucket_experiment_key(experiment_key: str, bucket_version: int = 0) -> str:
return experiment_key + "__" + str(bucket_version)

def _get_sticky_bucket_assignments(evalContext: EvaluationContext,
attr: Optional[str] = None,
fallback: Optional[str] = None) -> Dict[str, str]:
Expand Down Expand Up @@ -631,9 +638,9 @@ def _get_sticky_bucket_variation(

return {'variation': variation}

def run_experiment(experiment: Experiment,
featureId: Optional[str] = None,
evalContext: Optional[EvaluationContext] = None,
def run_experiment(experiment: Experiment,
featureId: Optional[str] = None,
evalContext: Optional[EvaluationContext] = None,
tracking_cb: Optional[Callable[[Experiment, Result, UserContext], None]] = None
) -> Result:
if evalContext is None:
Expand Down Expand Up @@ -890,7 +897,7 @@ def _generate_sticky_bucket_assignment_doc(attribute_name: str, attribute_value:
},
'changed': changed
}

def _getExperimentResult(
experiment: Experiment,
evalContext: EvaluationContext,
Expand Down Expand Up @@ -924,4 +931,4 @@ def _getExperimentResult(
meta=meta,
bucket=bucket,
stickyBucketUsed=stickyBucketUsed
)
)
Loading