From 068c5b5cfe33fdb547779a72f4e579c784ca9e4f Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 15 Sep 2025 11:11:07 +0300 Subject: [PATCH 01/11] implememnt remot eval, track data, add unit test --- demo.py | 75 +++++++++++++++ growthbook/common_types.py | 85 ++++++++++++++++- growthbook/core.py | 37 +++++--- growthbook/growthbook.py | 159 ++++++++++++++++++++++++-------- growthbook/growthbook_client.py | 100 ++++++++++++-------- tests/test_growthbook.py | 79 ++++++++++++---- 6 files changed, 420 insertions(+), 115 deletions(-) create mode 100644 demo.py diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..e8e1293 --- /dev/null +++ b/demo.py @@ -0,0 +1,75 @@ +import asyncio + +from aiohttp import payload_type + +from growthbook import GrowthBookClient, Options, UserContext, Experiment, GrowthBook + + +def my_tracking_callback(experiment, result, user): + print(f"📊 Tracking: {experiment.key}, variation={result.variationId}, user={user.attributes}") + +async def main(): + gb = GrowthBook( + attributes={"id": 1}, + trackingCallback=my_tracking_callback, + api_host="https://6526d8a46e76.ngrok-free.app", + client_key="sdk-HS7oAdaI8Yi4Bh" + ) + gb.eval_feature() + # 1. Ініціалізація GrowthBook + client = GrowthBookClient( + Options( + api_host="https://6526d8a46e76.ngrok-free.app", + client_key="sdk-HS7oAdaI8Yi4Bh", + # заміни на свій client key, + remote_eval = True, + global_attributes={"id": 1}, + ) + ) + + try: + # 2. Завантажуємо фічі + success = await client.initialize() + if not success: + print("❌ Не вдалося ініціалізувати GrowthBook клієнт") + return + print("✅ GrowthBook клієнт готовий") + + # 3. Створюємо користувача + user = UserContext( + attributes={ + "id": "user_123", + "country": "US", + "premium": True + } + ) + + # 4. Перевіряємо просту фічу + if await client.is_on("new-boolean-feature-september", user): + print("🟢 Нова домашня сторінка увімкнена!") + else: + print("🔴 Нова домашня сторінка вимкнена!") + + # 5. Дістаємо значення фічі з fallback + color = await client.get_feature_value("new-boolean-feature-september", True, user) + print(f"🎨 Колір кнопки: {color}") + + # 6. Запускаємо експеримент + experiment = Experiment( + key="pricing-test", + variations=["$9.99", "$14.99", "$19.99"] + ) + + result = await client.run(experiment, user) + if result.inExperiment: + print(f"🧪 Користувач у експерименті: {result.value}") + else: + print("⚪ Користувач не включений в експеримент") + + finally: + # 7. Закриваємо клієнт + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index d1b795f..8e48f2a 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -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): @@ -13,6 +14,22 @@ from enum import Enum from abc import ABC, abstractmethod +class ExperimentTracker: + def __init__(self): + self.tracked_experiments: Dict[str, bool] = OrderedDict() + + def track_experiment(self, experiment_id: str) -> None: + self.tracked_experiments[experiment_id] = True + if len(self.tracked_experiments) > 30: + self.tracked_experiments.popitem() + + def is_experiment_tracked(self, experiment_id: str) -> bool: + + return self.tracked_experiments.get(experiment_id) is not None + + def clear_tracked_experiments(self) -> None: + self.tracked_experiments.clear() + class VariationMeta(TypedDict): key: str name: str @@ -197,6 +214,25 @@ def to_dict(self) -> dict: obj["passthrough"] = True 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 передамо як окремий словник + meta={ + "name": data.get("name"), + "key": data.get("key"), + "passthrough": data.get("passthrough"), + } + ) class FeatureResult(object): def __init__( @@ -230,6 +266,17 @@ def to_dict(self) -> dict: 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=None, rules: list = []) -> None: self.defaultValue = defaultValue @@ -261,6 +308,7 @@ def __init__(self, defaultValue=None, rules: list = []) -> 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: @@ -269,6 +317,17 @@ def to_dict(self) -> dict: "rules": [rule.to_dict() for rule in self.rules], } +@dataclass +class TrackData: + experiment: Experiment + result: FeatureResult + + 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, @@ -294,6 +353,7 @@ def __init__( bucketVersion: int = None, minBucketVersion: int = None, parentConditions: List[dict] = None, + tracks: List[TrackData] = None ) -> None: if disableStickyBucketing: @@ -321,6 +381,20 @@ 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) + else: + self.tracks.append( + TrackData( + experiment=Experiment(**t["experiment"]) if isinstance(t.get("experiment"), + dict) else t.get("experiment"), + result=FeatureResult.from_dict(t["result"]) if isinstance(t.get("result"), dict) else t.get( + "result") + ) + ) def to_dict(self) -> dict: data: Dict[str, Any] = {} @@ -368,6 +442,8 @@ def to_dict(self) -> dict: data["minBucketVersion"] = self.minBucketVersion if self.parentConditions: data["parentConditions"] = self.parentConditions + if self.tracks is not None: + data["tracks"] = [track.to_dict() for track in self.tracks] return data @@ -394,7 +470,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) @@ -421,19 +497,22 @@ 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 tracking_plugins: Optional[List[Any]] = None - + remote_eval: bool = False + global_attributes: Dict[str, Any] = field(default_factory=dict) + forced_features: Dict[str, Any] = None @dataclass class GlobalContext: options: Options features: Dict[str, Any] = field(default_factory=dict) saved_groups: Dict[str, Any] = field(default_factory=dict) + experiment_tracker: ExperimentTracker = ExperimentTracker() @dataclass class EvaluationContext: diff --git a/growthbook/core.py b/growthbook/core.py index d53a3b2..fab4adb 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -4,8 +4,8 @@ from urllib.parse import urlparse, parse_qs from typing import Callable, Optional, Any, Set, Tuple, List, Dict -from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta - +from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta, \ + ExperimentTracker logger = logging.getLogger("growthbook.core") @@ -299,7 +299,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 @@ -420,7 +420,7 @@ 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") @@ -428,7 +428,7 @@ def eval_feature( 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] @@ -479,6 +479,14 @@ def eval_feature( ) continue + tracks = rule.tracks + + if tracks: + for track in tracks: + tracked_experiment = track.experiment + tracked_experiment_result = track.result.experimentResult + 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) @@ -540,7 +548,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": @@ -549,7 +557,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" @@ -558,7 +566,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: str = None, fallback: str = None) -> Dict[str, str]: @@ -631,9 +639,9 @@ def _get_sticky_bucket_variation( return {'variation': variation} -def run_experiment(experiment: Experiment, - featureId: Optional[str] = None, - evalContext: EvaluationContext = None, +def run_experiment(experiment: Experiment, + featureId: Optional[str] = None, + evalContext: EvaluationContext = None, tracking_cb: Callable[[Experiment, Result, UserContext], None] = None ) -> Result: if evalContext is None: @@ -858,8 +866,7 @@ def run_experiment(experiment: Experiment, evalContext.global_ctx.options.sticky_bucket_service.save_assignments(doc) # 14. Fire the tracking callback if set - if tracking_cb: - tracking_cb(experiment, result, evalContext.user) + tracking_cb(experiment, result, evalContext.user) # 15. Return the result logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key) @@ -885,7 +892,7 @@ def _generate_sticky_bucket_assignment_doc(attribute_name: str, attribute_value: }, 'changed': changed } - + def _getExperimentResult( experiment: Experiment, evalContext: EvaluationContext, @@ -919,4 +926,4 @@ def _getExperimentResult( meta=meta, bucket=bucket, stickyBucketUsed=stickyBucketUsed - ) \ No newline at end of file + ) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 4ef7660..3184eb6 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -13,14 +13,14 @@ from abc import ABC, abstractmethod from typing import Optional, Any, Set, Tuple, List, Dict, Callable -from .common_types import ( EvaluationContext, - Experiment, - FeatureResult, +from .common_types import ( EvaluationContext, + Experiment, + FeatureResult, Feature, - GlobalContext, - Options, - Result, StackContext, - UserContext, + GlobalContext, + Options, + Result, StackContext, + UserContext, AbstractStickyBucketService, FeatureRule ) @@ -170,7 +170,7 @@ def _get_sse_url(self, api_host: str, client_key: str) -> str: async def _init_session(self): url = self._get_sse_url(self.api_host, self.client_key) - + while self.is_running: try: async with aiohttp.ClientSession(headers=self.headers) as session: @@ -224,7 +224,7 @@ async def _close_session(self): def _run_sse_channel(self): self._loop = asyncio.new_event_loop() - + try: self._loop.run_until_complete(self._init_session()) except asyncio.CancelledError: @@ -282,16 +282,16 @@ def _notify_feature_update_callbacks(self, features_data: Dict) -> None: # Loads features with an in-memory cache in front using stale-while-revalidate approach def load_features( - self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600 + self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600, remote_eval: bool = False, payload: Optional[Dict[str, Any]] = None ) -> Optional[Dict]: if not client_key: raise ValueError("Must specify `client_key` to refresh features") - + key = api_host + "::" + client_key cached = self.cache.get(key) if not cached: - res = self._fetch_features(api_host, client_key, decryption_key) + res = self._fetch_features(api_host, client_key, decryption_key, remote_eval, payload) if res is not None: self.cache.set(key, res, ttl) logger.debug("Fetched features from API, stored in cache") @@ -299,15 +299,16 @@ def load_features( self._notify_feature_update_callbacks(res) return res return cached - + async def load_features_async( - self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600 + self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600, remote_eval: bool = False, + payload: Optional[Dict[str, Any]] = None ) -> Optional[Dict]: key = api_host + "::" + client_key cached = self.cache.get(key) if not cached: - res = await self._fetch_features_async(api_host, client_key, decryption_key) + res = await self._fetch_features_async(api_host, client_key, decryption_key, remote_eval, payload) if res is not None: self.cache.set(key, res, ttl) logger.debug("Fetched features from API, stored in cache") @@ -320,7 +321,37 @@ async def load_features_async( def _get(self, url: str): self.http = self.http or PoolManager() return self.http.request("GET", url) - + + def _post(self, url: str, payload: Dict, headers: Optional[Dict] = None): + self.http = self.http or PoolManager() + encoded_body = json.dumps(payload).encode("utf-8") + return self.http.request( + "POST", + url, + body=encoded_body, + headers=headers or {"Content-Type": "application/json"}, + ) + + def _fetch_and_decode_post(self, api_host: str, client_key: str, payload: Dict) -> Optional[Dict]: + try: + r = self._post( + self._get_features_url(api_host, client_key, remote_eval=True), + payload=payload + ) + + if r.status >= 400: + logger.warning( + "Failed to fetch features, received status code %d", r.status + ) + return None + + decoded = json.loads(r.data.decode("utf-8")) + return decoded + + except Exception as e: + logger.warning("Failed to decode feature JSON from API: %s", e) + return None + def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]: try: r = self._get(self._get_features_url(api_host, client_key)) @@ -334,7 +365,7 @@ def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]: except Exception: logger.warning("Failed to decode feature JSON from GrowthBook API") return None - + async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]: try: url = self._get_features_url(api_host, client_key) @@ -351,7 +382,28 @@ 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 - + + async def _fetch_and_decode_post_async(self, api_host: str, client_key: str, payload: Dict) -> Optional[Dict]: + try: + url = self._get_features_url(api_host, client_key, remote_eval=True) + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload) as response: + if response.status >= 400: + logger.warning("Failed to fetch features for remote evaluation, received status code %d", response.status) + return None + + # aiohttp's .json() method decodes the JSON response + decoded = await response.json() + return decoded + + except aiohttp.ClientError as e: + logger.warning(f"HTTP request failed: {e}") + return None + except Exception as e: + logger.warning(f"Failed to decode feature JSON from API in _fetch_and_decode_post_async function: {e}") + return None + def decrypt_response(self, data, decryption_key: str): if "encryptedFeatures" in data: if not decryption_key: @@ -367,7 +419,7 @@ def decrypt_response(self, data, decryption_key: str): return None 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") @@ -380,25 +432,41 @@ def decrypt_response(self, data, decryption_key: str): 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 = "" + self, api_host: str, client_key: str, decryption_key: str = "", remote_eval: bool = False, payload: Optional[Dict[str, Any]] = None, ) -> Optional[Dict]: - decoded = self._fetch_and_decode(api_host, client_key) + + if remote_eval: + if not payload: + logger.error("Payload is required for remote_eval POST request.") + return None + decoded = self._fetch_and_decode_post(api_host, client_key, payload) + else: + 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 = "" + self, api_host: str, client_key: str, decryption_key: str = "", remote_eval: bool = False, + payload: Optional[Dict[str, Any]] = None ) -> Optional[Dict]: - decoded = await self._fetch_and_decode_async(api_host, client_key) + if remote_eval: + if not payload: + logger.error("Payload is required for remote_eval POST request.") + return None + decoded = await self._fetch_and_decode_post_async(api_host, client_key, payload) + else: + decoded = await self._fetch_and_decode_async(api_host, client_key) + if not decoded: return None @@ -417,9 +485,10 @@ def stopAutoRefresh(self): self.sse_client.disconnect() @staticmethod - def _get_features_url(api_host: str, client_key: str) -> str: + def _get_features_url(api_host: str, client_key: str, remote_eval: bool = False) -> str: api_host = (api_host or "https://cdn.growthbook.io").rstrip("/") - return api_host + "/api/features/" + client_key + remote_eval_path = api_host + "/api/eval/" + client_key + return remote_eval_path if remote_eval else api_host + "/api/features/" + client_key # Singleton instance @@ -451,6 +520,8 @@ def __init__( groups: dict = {}, overrides: dict = {}, forcedVariations: dict = {}, + remoteEval: bool = False, + payload: Optional[Dict[str, Any]] = None ): self._enabled = enabled self._attributes = attributes @@ -486,6 +557,9 @@ def __init__( self._plugins: List = plugins or [] self._initialized_plugins: List = [] + self._remoteEval = remoteEval + self._payload = payload + self._global_ctx = GlobalContext( options=Options( url=self._url, @@ -500,7 +574,7 @@ def __init__( ), features={}, saved_groups=self._saved_groups - ) + ) # Create a user context for the current user self._user_ctx: UserContext = UserContext( url=self._url, @@ -534,7 +608,7 @@ def _on_feature_update(self, features_data: Dict) -> None: def load_features(self) -> None: response = feature_repo.load_features( - self._api_host, self._client_key, self._decryption_key, self._cache_ttl + self._api_host, self._client_key, self._decryption_key, self._cache_ttl, self._remoteEval, self_payload ) if response is not None and "features" in response.keys(): self.setFeatures(response["features"]) @@ -561,7 +635,7 @@ def _features_event_handler(self, features): decoded = json.loads(features) if not decoded: return None - + data = feature_repo.decrypt_response(decoded, self._decryption_key) if data is not None: @@ -583,9 +657,9 @@ def _dispatch_sse_event(self, event_data): def startAutoRefresh(self): if not self._client_key: raise ValueError("Must specify `client_key` to start features streaming") - + feature_repo.startAutoRefresh( - api_host=self._api_host, + api_host=self._api_host, client_key=self._client_key, cb=self._dispatch_sse_event ) @@ -627,6 +701,9 @@ def set_attributes(self, attributes: dict) -> None: self._attributes = attributes self.refresh_sticky_buckets() + def set_payload(self, payload: Optional[Dict[str, Any]] = None) -> None: + self._payload = payload + # @deprecated, use get_attributes def getAttributes(self) -> dict: return self.get_attributes() @@ -637,11 +714,11 @@ def get_attributes(self) -> dict: def destroy(self) -> None: # Clean up plugins first self._cleanup_plugins() - + # Clean up feature update callback if self._client_key: feature_repo.remove_feature_update_callback(self._on_feature_update) - + self._subscriptions.clear() self._tracked.clear() self._assigned.clear() @@ -677,10 +754,10 @@ def get_feature_value(self, key: str, fallback): # @deprecated, use eval_feature def evalFeature(self, key: str) -> FeatureResult: return self.eval_feature(key) - + def _ensure_fresh_features(self) -> None: """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided""" - + if self._streaming or not self._client_key: return # Skip cache checks - SSE handles freshness for streaming users @@ -692,7 +769,7 @@ def _ensure_fresh_features(self) -> None: def _get_eval_context(self) -> EvaluationContext: # Lazy refresh: ensure features are fresh before evaluation self._ensure_fresh_features() - + # use the latest attributes for every evaluation. self._user_ctx.attributes = self._attributes self._user_ctx.url = self._url @@ -706,8 +783,8 @@ def _get_eval_context(self) -> EvaluationContext: ) def eval_feature(self, key: str) -> FeatureResult: - return core_eval_feature(key=key, - evalContext=self._get_eval_context(), + return core_eval_feature(key=key, + evalContext=self._get_eval_context(), callback_subscription=self._fireSubscriptions, tracking_cb=self._track ) @@ -722,7 +799,7 @@ def get_all_results(self): def _fireSubscriptions(self, experiment: Experiment, result: Result): if experiment is None: return - + prev = self._assigned.get(experiment.key, None) if ( not prev @@ -741,7 +818,7 @@ def _fireSubscriptions(self, experiment: Experiment, result: Result): def run(self, experiment: Experiment) -> Result: # result = self._run(experiment) - result = run_experiment(experiment=experiment, + result = run_experiment(experiment=experiment, evalContext=self._get_eval_context(), tracking_cb=self._track ) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index adcf2d9..33db747 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -43,9 +43,9 @@ def __call__(cls, *args, **kwargs): class BackoffStrategy: """Exponential backoff with jitter for failed requests""" def __init__( - self, - initial_delay: float = 1.0, - max_delay: float = 60.0, + self, + initial_delay: float = 1.0, + max_delay: float = 60.0, multiplier: float = 2.0, jitter: float = 0.1 ): @@ -59,7 +59,7 @@ def __init__( def next_delay(self) -> float: """Calculate next delay with jitter""" delay = min( - self.current_delay * (self.multiplier ** self.attempt), + self.current_delay * (self.multiplier ** self.attempt), self.max_delay ) # Add random jitter @@ -252,7 +252,7 @@ async def refresh_loop() -> None: async def start_feature_refresh(self, strategy: FeatureRefreshStrategy, callback=None): """Initialize feature refresh based on strategy""" self._refresh_callback = callback - + if strategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS: await self._start_sse_refresh() else: @@ -281,31 +281,37 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.stop_refresh() - + async def load_features_async( - self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 60 + self, + api_host: str, + client_key: str, + decryption_key: str = "", + ttl: int = 60, + remote_eval: bool = False, + payload: Optional[Dict[str, Any]] = None ) -> Optional[Dict]: # Use stored values when called internally if api_host == self._api_host and client_key == self._client_key: decryption_key = self._decryption_key ttl = self._cache_ttl - return await super().load_features_async(api_host, client_key, decryption_key, ttl) + return await super().load_features_async(api_host, client_key, decryption_key, ttl, remote_eval, payload) class GrowthBookClient: def __init__( self, options: Optional[Union[Dict[str, Any], Options]] = None - ): + ): self.options = ( options if isinstance(options, Options) else Options(**options) if options else Options() ) - + # Thread-safe tracking state self._tracked: Dict[str, bool] = {} # Access only within async context self._tracked_lock = threading.Lock() - + # Thread-safe subscription management self._subscriptions: Set[Callable[[Experiment, Result], None]] = set() self._subscriptions_lock = threading.Lock() @@ -316,25 +322,25 @@ def __init__( 'assignments': {} } self._sticky_bucket_cache_lock = False - + # Plugin support self._tracking_plugins: List[Any] = self.options.tracking_plugins or [] self._initialized_plugins: List[Any] = [] - + self._features_repository = ( EnhancedFeatureRepository( - self.options.api_host or "https://cdn.growthbook.io", - self.options.client_key or "", - self.options.decryption_key or "", + self.options.api_host or "https://cdn.growthbook.io", + self.options.client_key or "", + self.options.decryption_key or "", self.options.cache_ttl ) if self.options.client_key else None ) - + self._global_context: Optional[GlobalContext] = None self._context_lock = asyncio.Lock() - + # Initialize plugins self._initialize_plugins() @@ -383,8 +389,8 @@ def _fire_subscriptions(self, experiment: Experiment, result: Result) -> None: async def set_features(self, features: dict) -> None: await self._feature_update_callback({"features": features}) - - + + async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, Any]: """Refresh sticky bucket assignments only if attributes have changed""" if not self.options.sticky_bucket_service: @@ -394,7 +400,7 @@ async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, while not self._sticky_bucket_cache_lock: if attributes == self._sticky_bucket_cache['attributes']: return self._sticky_bucket_cache['assignments'] - + self._sticky_bucket_cache_lock = True try: assignments = self.options.sticky_bucket_service.get_all_assignments(attributes) @@ -403,7 +409,7 @@ async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, return assignments finally: self._sticky_bucket_cache_lock = False - + # Fallback return for edge case where loop condition is never satisfied return {} @@ -414,12 +420,32 @@ async def initialize(self) -> bool: return False try: + payload = None + + if self.options.remote_eval: + # Формуємо payload, аналогічно вашій логіці на Java + forced_features_for_payload = [] + if self.options.forced_features: + forced_features_for_payload = [ + [key, value] + for key, value in self.options.forced_features.items() + ] + + payload = { + "attributes": self.options.global_attributes, + "forcedFeatures": forced_features_for_payload, + "forcedVariations": self.options.forced_variations, + "url": self.options.api_host + } + # Initial feature load initial_features = await self._features_repository.load_features_async( - self.options.api_host or "https://cdn.growthbook.io", - self.options.client_key or "", - self.options.decryption_key or "", - self.options.cache_ttl + self.options.api_host or "https://cdn.growthbook.io", + self.options.client_key or "", + self.options.decryption_key or "", + self.options.cache_ttl, + self.options.remote_eval, + payload ) if not initial_features: logger.error("Failed to load initial features") @@ -427,15 +453,15 @@ async def initialize(self) -> bool: # Create global context with initial features await self._feature_update_callback(initial_features) - + # Set up callback for future updates self._features_repository.add_callback(self._feature_update_callback) - + # Start feature refresh refresh_strategy = self.options.refresh_strategy or FeatureRefreshStrategy.STALE_WHILE_REVALIDATE await self._features_repository.start_feature_refresh(refresh_strategy) return True - + except Exception as e: logger.error(f"Initialization failed: {str(e)}", exc_info=True) traceback.print_exc() @@ -482,10 +508,10 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati """Create evaluation context for feature evaluation""" if self._global_context is None: raise RuntimeError("GrowthBook client not properly initialized") - + # Get sticky bucket assignments if needed sticky_assignments = await self._refresh_sticky_buckets(user_context.attributes) - + # update user context with sticky bucket assignments user_context.sticky_bucket_assignment_docs = sticky_assignments @@ -507,13 +533,13 @@ async def is_on(self, key: str, user_context: UserContext) -> bool: async with self._context_lock: context = await self.create_evaluation_context(user_context) return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on - + async def is_off(self, key: str, user_context: UserContext) -> bool: """Check if a feature is set to off with proper async context management""" async with self._context_lock: context = await self.create_evaluation_context(user_context) return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).off - + async def get_feature_value(self, key: str, fallback: Any, user_context: UserContext) -> Any: async with self._context_lock: context = await self.create_evaluation_context(user_context) @@ -525,14 +551,14 @@ async def run(self, experiment: Experiment, user_context: UserContext) -> Result async with self._context_lock: context = await self.create_evaluation_context(user_context) result = run_experiment( - experiment=experiment, + experiment=experiment, evalContext=context, tracking_cb=self._track ) # Fire subscriptions synchronously self._fire_subscriptions(experiment, result) return result - + async def close(self) -> None: """Clean shutdown with proper cleanup""" if self._features_repository: @@ -547,7 +573,7 @@ async def close(self) -> None: # Clear context async with self._context_lock: self._global_context = None - + # Cleanup plugins self._cleanup_plugins() @@ -579,4 +605,4 @@ def _cleanup_plugins(self) -> None: logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}") except Exception as e: logger.error(f"Error cleaning up plugin {plugin}: {e}") - self._initialized_plugins.clear() \ No newline at end of file + self._initialized_plugins.clear() diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index d6ae08d..27afe65 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -10,7 +10,7 @@ InMemoryStickyBucketService, decrypt, feature_repo, - logger, + logger, FeatureRepository, ) from growthbook.core import ( @@ -160,7 +160,7 @@ def test_stickyBucket(stickyBucket_data): gb = GrowthBook(**ctx) res = gb.eval_feature(key) - + if not res.experimentResult: assert None == expected_result else: @@ -926,39 +926,39 @@ def test_ttl_automatic_feature_refresh(mocker): {"features": {"test_feature": {"defaultValue": False}}, "savedGroups": {}}, {"features": {"test_feature": {"defaultValue": True}}, "savedGroups": {}} ] - + call_count = 0 def mock_fetch_features(api_host, client_key, decryption_key=""): nonlocal call_count response = mock_responses[min(call_count, len(mock_responses) - 1)] call_count += 1 return response - + # Clear cache and mock the fetch method feature_repo.clear_cache() m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features) - + # Create GrowthBook instance with short TTL gb = GrowthBook( api_host="https://cdn.growthbook.io", client_key="test-key", cache_ttl=1 # 1 second TTL for testing ) - + try: # Initial evaluation - should trigger first load assert gb.is_on('test_feature') == False assert call_count == 1 - + # Manually expire the cache by setting expiry time to past cache_key = "https://cdn.growthbook.io::test-key" if hasattr(feature_repo.cache, 'cache') and cache_key in feature_repo.cache.cache: feature_repo.cache.cache[cache_key].expires = time() - 10 - + # Next evaluation should automatically refresh cache and update features assert gb.is_on('test_feature') == True assert call_count == 2 - + finally: gb.destroy() feature_repo.clear_cache() @@ -970,43 +970,84 @@ def test_multiple_instances_get_updated_on_cache_expiry(mocker): {"features": {"test_feature": {"defaultValue": "v1"}}, "savedGroups": {}}, {"features": {"test_feature": {"defaultValue": "v2"}}, "savedGroups": {}} ] - + call_count = 0 def mock_fetch_features(api_host, client_key, decryption_key=""): nonlocal call_count response = mock_responses[min(call_count, len(mock_responses) - 1)] call_count += 1 return response - + feature_repo.clear_cache() m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features) - + # Create multiple GrowthBook instances gb1 = GrowthBook(api_host="https://cdn.growthbook.io", client_key="test-key") gb2 = GrowthBook(api_host="https://cdn.growthbook.io", client_key="test-key") - + try: # Initial evaluation from first instance - should trigger first load assert gb1.get_feature_value('test_feature', 'default') == "v1" assert call_count == 1 - + # Second instance should use cached value (no additional API call) assert gb2.get_feature_value('test_feature', 'default') == "v1" assert call_count == 1 # Still 1, used cache - + # Manually expire the cache cache_key = "https://cdn.growthbook.io::test-key" if hasattr(feature_repo.cache, 'cache') and cache_key in feature_repo.cache.cache: feature_repo.cache.cache[cache_key].expires = time() - 10 - + # Next evaluation should automatically refresh and notify both instances via callbacks assert gb1.get_feature_value('test_feature', 'default') == "v2" assert call_count == 2 - + # Second instance should also have the updated value due to callbacks assert gb2.get_feature_value('test_feature', 'default') == "v2" - + finally: gb1.destroy() gb2.destroy() - feature_repo.clear_cache() \ No newline at end of file + feature_repo.clear_cache() + + +def test_post_request(mocker): + fetcher = feature_repo + mock_http = mocker.Mock() + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.data = b'{"ok": true}' + + mock_http.request.return_value = mock_response + fetcher.http = mock_http + + result = fetcher._post("https://cdn.growthbook.io/api/eval/abc123", {"foo": "bar"}) + + mock_http.request.assert_called_once_with( + "POST", + "https://cdn.growthbook.io/api/eval/abc123", + body=json.dumps({"foo": "bar"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + assert result.data == b'{"ok": true}' + +def test_fetch_and_decode_remote_eval(mocker): + fetcher = feature_repo + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.data = b'{"feature": "value"}' + + mocker.patch.object(fetcher, "_post", return_value=mock_response) + + result = fetcher._fetch_and_decode_post( + "https://cdn.growthbook.io", + "abc123", + payload={"id": "user_1"} + ) + + assert result == {"feature": "value"} + +def test_get_features_url_remote_eval(): + url = feature_repo._get_features_url("https://cdn.growthbook.io", "abc123", remote_eval=True) + assert url == "https://cdn.growthbook.io/api/eval/abc123" From ef013d5bcbb3a54866ac293dfe32fd391350254e Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 15 Sep 2025 12:04:20 +0300 Subject: [PATCH 02/11] fix unit tests --- growthbook/common_types.py | 2 +- growthbook/core.py | 5 +++-- growthbook/growthbook.py | 4 ++-- tests/test_growthbook.py | 6 ++++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 8e48f2a..bcde713 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -442,7 +442,7 @@ def to_dict(self) -> dict: data["minBucketVersion"] = self.minBucketVersion if self.parentConditions: data["parentConditions"] = self.parentConditions - if self.tracks is not None: + if self.tracks: data["tracks"] = [track.to_dict() for track in self.tracks] return data diff --git a/growthbook/core.py b/growthbook/core.py index fab4adb..1ea4a01 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -481,7 +481,7 @@ def eval_feature( tracks = rule.tracks - if tracks: + if tracks and tracking_cb: for track in tracks: tracked_experiment = track.experiment tracked_experiment_result = track.result.experimentResult @@ -866,7 +866,8 @@ def run_experiment(experiment: Experiment, evalContext.global_ctx.options.sticky_bucket_service.save_assignments(doc) # 14. Fire the tracking callback if set - tracking_cb(experiment, result, evalContext.user) + if tracking_cb: + tracking_cb(experiment, result, evalContext.user) # 15. Return the result logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 3184eb6..af3a3e0 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -52,7 +52,7 @@ def decrypt(encrypted_str: str, key_str: str) -> str: iv = b64decode(iv_str) ct = b64decode(ct_str) - cipher = Cipher(algorithms.AES128(key), modes.CBC(iv)) + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) decryptor = cipher.decryptor() decrypted = decryptor.update(ct) + decryptor.finalize() @@ -608,7 +608,7 @@ def _on_feature_update(self, features_data: Dict) -> None: def load_features(self) -> None: response = feature_repo.load_features( - self._api_host, self._client_key, self._decryption_key, self._cache_ttl, self._remoteEval, self_payload + self._api_host, self._client_key, self._decryption_key, self._cache_ttl, self._remoteEval, self._payload ) if response is not None and "features" in response.keys(): self.setFeatures(response["features"]) diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index 27afe65..c97e133 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -2,6 +2,8 @@ import json import os +from typing import Optional, Any, Dict + from growthbook import ( FeatureRule, GrowthBook, @@ -928,7 +930,7 @@ def test_ttl_automatic_feature_refresh(mocker): ] call_count = 0 - def mock_fetch_features(api_host, client_key, decryption_key=""): + def mock_fetch_features(api_host, client_key, decryption_key="", remote_eval: bool = False, payload: Optional[Dict[str, Any]] = None): nonlocal call_count response = mock_responses[min(call_count, len(mock_responses) - 1)] call_count += 1 @@ -972,7 +974,7 @@ def test_multiple_instances_get_updated_on_cache_expiry(mocker): ] call_count = 0 - def mock_fetch_features(api_host, client_key, decryption_key=""): + def mock_fetch_features(api_host, client_key, decryption_key="", remote_eval: bool = False, payload: Optional[Dict[str, Any]] = None): nonlocal call_count response = mock_responses[min(call_count, len(mock_responses) - 1)] call_count += 1 From eb1da3012c79ce2014940dd02d71ca0af461b4b5 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 15 Sep 2025 12:08:44 +0300 Subject: [PATCH 03/11] clean up code --- demo.py | 75 --------------------------------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 demo.py diff --git a/demo.py b/demo.py deleted file mode 100644 index e8e1293..0000000 --- a/demo.py +++ /dev/null @@ -1,75 +0,0 @@ -import asyncio - -from aiohttp import payload_type - -from growthbook import GrowthBookClient, Options, UserContext, Experiment, GrowthBook - - -def my_tracking_callback(experiment, result, user): - print(f"📊 Tracking: {experiment.key}, variation={result.variationId}, user={user.attributes}") - -async def main(): - gb = GrowthBook( - attributes={"id": 1}, - trackingCallback=my_tracking_callback, - api_host="https://6526d8a46e76.ngrok-free.app", - client_key="sdk-HS7oAdaI8Yi4Bh" - ) - gb.eval_feature() - # 1. Ініціалізація GrowthBook - client = GrowthBookClient( - Options( - api_host="https://6526d8a46e76.ngrok-free.app", - client_key="sdk-HS7oAdaI8Yi4Bh", - # заміни на свій client key, - remote_eval = True, - global_attributes={"id": 1}, - ) - ) - - try: - # 2. Завантажуємо фічі - success = await client.initialize() - if not success: - print("❌ Не вдалося ініціалізувати GrowthBook клієнт") - return - print("✅ GrowthBook клієнт готовий") - - # 3. Створюємо користувача - user = UserContext( - attributes={ - "id": "user_123", - "country": "US", - "premium": True - } - ) - - # 4. Перевіряємо просту фічу - if await client.is_on("new-boolean-feature-september", user): - print("🟢 Нова домашня сторінка увімкнена!") - else: - print("🔴 Нова домашня сторінка вимкнена!") - - # 5. Дістаємо значення фічі з fallback - color = await client.get_feature_value("new-boolean-feature-september", True, user) - print(f"🎨 Колір кнопки: {color}") - - # 6. Запускаємо експеримент - experiment = Experiment( - key="pricing-test", - variations=["$9.99", "$14.99", "$19.99"] - ) - - result = await client.run(experiment, user) - if result.inExperiment: - print(f"🧪 Користувач у експерименті: {result.value}") - else: - print("⚪ Користувач не включений в експеримент") - - finally: - # 7. Закриваємо клієнт - await client.close() - - -if __name__ == "__main__": - asyncio.run(main()) From d8c6172fb53dfe904fe6ae47b66250873815e192 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Tue, 16 Sep 2025 00:13:04 +0300 Subject: [PATCH 04/11] remove Experiment tracker, fix warnings --- growthbook/common_types.py | 54 +++++++++++++------------------------- growthbook/core.py | 3 +-- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index bcde713..f0bdcaa 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -14,22 +14,6 @@ from enum import Enum from abc import ABC, abstractmethod -class ExperimentTracker: - def __init__(self): - self.tracked_experiments: Dict[str, bool] = OrderedDict() - - def track_experiment(self, experiment_id: str) -> None: - self.tracked_experiments[experiment_id] = True - if len(self.tracked_experiments) > 30: - self.tracked_experiments.popitem() - - def is_experiment_tracked(self, experiment_id: str) -> bool: - - return self.tracked_experiments.get(experiment_id) is not None - - def clear_tracked_experiments(self) -> None: - self.tracked_experiments.clear() - class VariationMeta(TypedDict): key: str name: str @@ -160,14 +144,14 @@ def update(self, data: dict) -> None: class Result(object): def __init__( self, - variationId: int, - inExperiment: bool, + variationId: Optional[int], + inExperiment: Optional[bool], value, - hashUsed: bool, - hashAttribute: str, - hashValue: str, + hashUsed: Optional[bool], + hashAttribute: Optional[str], + hashValue: Optional[str], featureId: Optional[str], - meta: VariationMeta = None, + meta: Optional[VariationMeta] = None, bucket: float = None, stickyBucketUsed: bool = False, ) -> None: @@ -181,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: obj = { @@ -226,7 +210,6 @@ def from_dict(data: dict) -> "Result": featureId=data.get("featureId"), bucket=data.get("bucket"), stickyBucketUsed=data.get("stickyBucketUsed", False), - # meta передамо як окремий словник meta={ "name": data.get("name"), "key": data.get("key"), @@ -238,10 +221,10 @@ class FeatureResult(object): def __init__( self, value, - source: str, - experiment: Experiment = None, - experimentResult: Result = None, - ruleId: str = None, + source: Optional[str] = None, + experiment: Optional["Experiment"] = None, + experimentResult: Optional["Result"] = None, + ruleId: Optional[str] = None, ) -> None: self.value = value self.source = source @@ -254,7 +237,7 @@ def __init__( def to_dict(self) -> dict: data = { "value": self.value, - "source": self.source, + "source": self.source or "", "on": self.on, "off": self.off, "ruleId": self.ruleId or "", @@ -512,7 +495,6 @@ class GlobalContext: options: Options features: Dict[str, Any] = field(default_factory=dict) saved_groups: Dict[str, Any] = field(default_factory=dict) - experiment_tracker: ExperimentTracker = ExperimentTracker() @dataclass class EvaluationContext: diff --git a/growthbook/core.py b/growthbook/core.py index 1ea4a01..d5288de 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -4,8 +4,7 @@ from urllib.parse import urlparse, parse_qs from typing import Callable, Optional, Any, Set, Tuple, List, Dict -from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta, \ - ExperimentTracker +from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta logger = logging.getLogger("growthbook.core") From 1672df92375083f87da8ab7c2833fb52e5a54e60 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Tue, 16 Sep 2025 00:23:26 +0300 Subject: [PATCH 05/11] Fix warning with optional variables --- growthbook/common_types.py | 6 +++--- growthbook/growthbook.py | 9 +++++---- growthbook/growthbook_client.py | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index f0bdcaa..e889f66 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -15,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): diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index af3a3e0..de10cd9 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -834,10 +834,10 @@ def _track(self, experiment: Experiment, result: Result, user_context: UserConte if not self._trackingCallback: return None key = ( - result.hashAttribute - + str(result.hashValue) - + experiment.key - + str(result.variationId) + (result.hashAttribute or "") + + str(result.hashValue or "") + + (experiment.key or "") + + str(result.variationId or "") ) if not self._tracked.get(key): try: @@ -845,6 +845,7 @@ def _track(self, experiment: Experiment, result: Result, user_context: UserConte self._tracked[key] = True except Exception: pass + return None def _derive_sticky_bucket_identifier_attributes(self) -> List[str]: attributes = set() diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 33db747..26a91fb 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -352,10 +352,10 @@ def _track(self, experiment: Experiment, result: Result, user_context: UserConte # Create unique key for this tracking event key = ( - result.hashAttribute - + str(result.hashValue) - + experiment.key - + str(result.variationId) + (result.hashAttribute or "") + + str(result.hashValue or "") + + (experiment.key or "") + + str(result.variationId or "") ) with self._tracked_lock: From 60c386dd8d6c2fe70c37b744b3fef7791b21e5ee Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Tue, 16 Sep 2025 00:28:13 +0300 Subject: [PATCH 06/11] initialize force features with empty dictionary in Options --- growthbook/common_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index e889f66..9a1e110 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -488,7 +488,7 @@ class Options: tracking_plugins: Optional[List[Any]] = None remote_eval: bool = False global_attributes: Dict[str, Any] = field(default_factory=dict) - forced_features: Dict[str, Any] = None + forced_features: Dict[str, Any] = field(default_factory=dict) @dataclass class GlobalContext: From 84cc24325ccf198ba45a0c9b439269bce4d9c475 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Tue, 16 Sep 2025 00:34:06 +0300 Subject: [PATCH 07/11] remove comments --- growthbook/growthbook_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 26a91fb..368697b 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -423,7 +423,6 @@ async def initialize(self) -> bool: payload = None if self.options.remote_eval: - # Формуємо payload, аналогічно вашій логіці на Java forced_features_for_payload = [] if self.options.forced_features: forced_features_for_payload = [ From d9c840988d36b02d6f10fa73cc1841a492f6c871 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Fri, 5 Dec 2025 15:26:50 +0200 Subject: [PATCH 08/11] fix code style --- growthbook/growthbook.py | 6 ++++-- tests/test_growthbook.py | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index dc66fbb..755f44b 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -523,7 +523,8 @@ async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optio async with session.get(url, headers=headers) as response: # Handle 304 Not Modified - content hasn't changed if response.status == 304: - logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)") + logger.debug( + f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)") if cached_data is not None: logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)") return cached_data @@ -549,7 +550,8 @@ async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optio if cached_etag: logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...") else: - logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)") + logger.debug( + f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)") logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries") else: logger.debug("[Async] No ETag header in response") diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index 26aeeb4..bb00778 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -1215,7 +1215,6 @@ def test_get_features_url_remote_eval(): assert url == "https://cdn.growthbook.io/api/eval/abc123" - def test_stale_while_revalidate_basic_functionality(mocker): """Test basic stale-while-revalidate functionality""" # Mock responses - first call returns v1, subsequent calls return v2 From 54588eae0c713a8e2bb3ba6676736eb459f54359 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 8 Dec 2025 04:35:19 +0200 Subject: [PATCH 09/11] fix feature usage mock callback in tests, fix is_on function after merge --- growthbook/growthbook_client.py | 1 - tests/test_growthbook.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index ce94485..3e581e7 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -536,7 +536,6 @@ async def is_on(self, key: str, user_context: UserContext) -> bool: """Check if a feature is enabled with proper async context management""" async with self._context_lock: context = await self.create_evaluation_context(user_context) - return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track) # Call feature usage callback if provided diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index bb00778..69f61ad 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -1224,7 +1224,7 @@ def test_stale_while_revalidate_basic_functionality(mocker): ] call_count = 0 - def mock_fetch_features(api_host, client_key, decryption_key=""): + def mock_fetch_features(api_host, client_key, decryption_key="", remoteEval = False, payload = None): nonlocal call_count response = mock_responses[min(call_count, len(mock_responses) - 1)] call_count += 1 @@ -1268,7 +1268,7 @@ def test_stale_while_revalidate_starts_background_task(mocker): mock_response = {"features": {"test_feature": {"defaultValue": "fresh"}}, "savedGroups": {}} call_count = 0 - def mock_fetch_features(api_host, client_key, decryption_key=""): + def mock_fetch_features(api_host, client_key, decryption_key="", remoteEval = False, payload = None): nonlocal call_count call_count += 1 return mock_response @@ -1303,7 +1303,7 @@ def test_stale_while_revalidate_disabled_fallback(mocker): mock_response = {"features": {"test_feature": {"defaultValue": "normal"}}, "savedGroups": {}} call_count = 0 - def mock_fetch_features(api_host, client_key, decryption_key=""): + def mock_fetch_features(api_host, client_key, decryption_key="", remoteEval = False, payload = None): nonlocal call_count call_count += 1 return mock_response From 8b70a02163b72d55972783ff3a781b9364f4c3e9 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 8 Dec 2025 14:19:41 +0200 Subject: [PATCH 10/11] fix: analyzer warnings --- growthbook/common_types.py | 4 ++-- growthbook/core.py | 2 +- growthbook/growthbook.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 0929d03..218f8bf 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -306,7 +306,7 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class TrackData: experiment: Experiment - result: FeatureResult + result: Result def to_dict(self) -> Dict[str, Any]: return { @@ -377,7 +377,7 @@ def __init__( TrackData( experiment=Experiment(**t["experiment"]) if isinstance(t.get("experiment"), dict) else t.get("experiment"), - result=FeatureResult.from_dict(t["result"]) if isinstance(t.get("result"), dict) else t.get( + result=Result.from_dict(t["result"]) if isinstance(t.get("result"), dict) else t.get( "result") ) ) diff --git a/growthbook/core.py b/growthbook/core.py index 54ceaba..e8ab99b 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -483,7 +483,7 @@ def eval_feature( if tracks and tracking_cb: for track in tracks: tracked_experiment = track.experiment - tracked_experiment_result = track.result.experimentResult + tracked_experiment_result = track.result tracking_cb(tracked_experiment, tracked_experiment_result, evalContext.user) logger.debug("Force value from rule, feature %s", key) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index e0db349..f38dfd6 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -436,7 +436,7 @@ def _fetch_and_decode_post(self, api_host: str, client_key: str, payload: Dict) return None decoded = json.loads(r.data.decode("utf-8")) - return decoded + return decoded # type: ignore[no-any-return] except Exception as e: logger.warning("Failed to decode feature JSON from API: %s", e) @@ -579,7 +579,7 @@ async def _fetch_and_decode_post_async(self, api_host: str, client_key: str, pay # aiohttp's .json() method decodes the JSON response decoded = await response.json() - return decoded + return decoded # type: ignore[no-any-return] except aiohttp.ClientError as e: logger.warning(f"HTTP request failed: {e}") From 210f47a8cbe5cbb7fca86d8fcb41f376f51162b0 Mon Sep 17 00:00:00 2001 From: Volodymyr Nazarkevych Date: Mon, 8 Dec 2025 14:24:01 +0200 Subject: [PATCH 11/11] fix: deserialize TrackData --- growthbook/common_types.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 218f8bf..92f9d99 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -372,15 +372,6 @@ def __init__( for t in tracks: if isinstance(t, TrackData): self.tracks.append(t) - else: - self.tracks.append( - TrackData( - experiment=Experiment(**t["experiment"]) if isinstance(t.get("experiment"), - dict) else t.get("experiment"), - result=Result.from_dict(t["result"]) if isinstance(t.get("result"), dict) else t.get( - "result") - ) - ) def to_dict(self) -> Dict[str, Any]: data: Dict[str, Any] = {}