diff --git a/growthbook/__init__.py b/growthbook/__init__.py index 8424714..31f215d 100644 --- a/growthbook/__init__.py +++ b/growthbook/__init__.py @@ -1,11 +1,13 @@ from .growthbook import * - from .growthbook_client import ( GrowthBookClient, EnhancedFeatureRepository, FeatureCache, - BackoffStrategy + BackoffStrategy, + InMemoryAsyncFeatureCache, + RedisAsyncFeatureCache ) +from .common_types import AbstractAsyncFeatureCache # Plugin support from .plugins import ( diff --git a/growthbook/common_types.py b/growthbook/common_types.py index c0a187b..b22165f 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -414,6 +414,18 @@ class UserContext: sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict) skip_all_experiments: bool = False +class AbstractAsyncFeatureCache(ABC): + @abstractmethod + async def get(self, key: str) -> Optional[Dict]: + pass + + @abstractmethod + async def set(self, key: str, value: Dict, ttl: int) -> None: + pass + + async def clear(self) -> None: + pass + @dataclass class Options: url: Optional[str] = None @@ -431,6 +443,7 @@ class Options: 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 + cache: Optional[AbstractAsyncFeatureCache] = None http_connect_timeout: Optional[int] = None http_read_timeout: Optional[int] = None diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 1ec8883..67a54e5 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -8,8 +8,9 @@ import asyncio import threading import traceback +import time from datetime import datetime -from growthbook import FeatureRepository, feature_repo +from .growthbook import FeatureRepository, feature_repo, CacheEntry from contextlib import asynccontextmanager from .core import eval_feature as core_eval_feature, run_experiment @@ -23,7 +24,8 @@ StackContext, FeatureResult, FeatureRefreshStrategy, - Experiment + Experiment, + AbstractAsyncFeatureCache ) logger = logging.getLogger("growthbook.growthbook_client") @@ -34,11 +36,17 @@ class SingletonMeta(type): _lock = threading.Lock() def __call__(cls, *args, **kwargs): + # Identify instance by api_host and client_key (args[0] and args[1]) + key = (args[0], args[1]) if len(args) >= 2 else "default" + with cls._lock: if cls not in cls._instances: + cls._instances[cls] = {} + + if key not in cls._instances[cls]: instance = super().__call__(*args, **kwargs) - cls._instances[cls] = instance - return cls._instances[cls] + cls._instances[cls][key] = instance + return cls._instances[cls][key] class BackoffStrategy: """Exponential backoff with jitter for failed requests""" @@ -101,12 +109,74 @@ def get_current_state(self) -> Dict[str, Any]: "savedGroups": self._cache['savedGroups'] } +class CacheEntry(object): + def __init__(self, value: Dict, ttl: int) -> None: + self.value = value + self.ttl = ttl + self.expires = time.time() + ttl + + def update(self, value: Dict): + self.value = value + self.expires = time.time() + self.ttl + +class InMemoryAsyncFeatureCache(AbstractAsyncFeatureCache): + def __init__(self) -> None: + self.cache: Dict[str, CacheEntry] = {} + + async def get(self, key: str) -> Optional[Dict]: + if key in self.cache: + entry = self.cache[key] + if entry.expires >= time.time(): + return entry.value + else: + del self.cache[key] + return None + + async def set(self, key: str, value: Dict, ttl: int) -> None: + if key in self.cache: + self.cache[key].update(value) + else: + self.cache[key] = CacheEntry(value, ttl) + + async def clear(self) -> None: + self.cache.clear() + +class RedisAsyncFeatureCache(AbstractAsyncFeatureCache): + def __init__(self, redis_url: str, key_prefix: str = "gb_cache:") -> None: + self.key_prefix = key_prefix + try: + import redis.asyncio as redis + self.redis = redis.from_url(redis_url, decode_responses=True) + except ImportError: + raise ImportError("redis package is required for RedisAsyncFeatureCache. Install it with `pip install redis`.") + + def _get_key(self, key: str) -> str: + return f"{self.key_prefix}{key}" + + async def get(self, key: str) -> Optional[Dict]: + data = await self.redis.get(self._get_key(key)) + if data: + return json.loads(data) + return None + + async def set(self, key: str, value: Dict, ttl: int) -> None: + await self.redis.set(self._get_key(key), json.dumps(value), ex=ttl) + + async def clear(self) -> None: + keys = await self.redis.keys(f"{self.key_prefix}*") + if keys: + await self.redis.delete(*keys) + + async def close(self) -> None: + await self.redis.close() + class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta): def __init__(self, api_host: str, client_key: str, decryption_key: str = "", cache_ttl: int = 60, + cache: Optional[AbstractAsyncFeatureCache] = None, http_connect_timeout: Optional[int] = None, http_read_timeout: Optional[int] = None): FeatureRepository.__init__(self) @@ -122,6 +192,7 @@ def __init__(self, self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = [] self._last_successful_refresh: Optional[datetime] = None self._refresh_in_progress = asyncio.Lock() + self.cache = cache if cache else InMemoryAsyncFeatureCache() self.http_connect_timeout = http_connect_timeout self.http_read_timeout = http_read_timeout @@ -346,7 +417,21 @@ async def load_features_async( 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) + + key = api_host + "::" + client_key + + # Try async cache first + cached = await self.cache.get(key) + if cached: + return cached + + # Fetch from network + res = await self._fetch_features_async(api_host, client_key, decryption_key) + if res is not None: + await self.cache.set(key, res, ttl) + return res + + return None class GrowthBookClient: def __init__( @@ -384,6 +469,7 @@ def __init__( self.options.client_key or "", self.options.decryption_key or "", self.options.cache_ttl, + self.options.cache, self.options.http_connect_timeout, self.options.http_read_timeout ) diff --git a/tests/test_async_custom_cache.py b/tests/test_async_custom_cache.py new file mode 100644 index 0000000..a8bb06f --- /dev/null +++ b/tests/test_async_custom_cache.py @@ -0,0 +1,73 @@ +import pytest +import asyncio +from typing import Dict, Optional +from growthbook import GrowthBookClient, AbstractAsyncFeatureCache, InMemoryAsyncFeatureCache, Options, EnhancedFeatureRepository + +@pytest.fixture(autouse=True) +async def cleanup(): + EnhancedFeatureRepository._instances = {} + yield + EnhancedFeatureRepository._instances = {} + +class CustomAsyncCache(AbstractAsyncFeatureCache): + def __init__(self): + self.cache = {} + self.get_calls = 0 + self.set_calls = 0 + + async def get(self, key: str) -> Optional[Dict]: + self.get_calls += 1 + return self.cache.get(key) + + async def set(self, key: str, value: Dict, ttl: int) -> None: + self.set_calls += 1 + self.cache[key] = value + + async def clear(self) -> None: + self.cache.clear() + +@pytest.mark.asyncio +async def test_default_async_cache(): + # Test that default cache is InMemoryAsyncFeatureCache + client = GrowthBookClient(options=Options(client_key="123", api_host="http://localhost")) + # Access private repo to check cache (white-box testing) + assert isinstance(client._features_repository.cache, InMemoryAsyncFeatureCache) + + # Clean up + await client.close() + +@pytest.mark.asyncio +async def test_custom_async_cache(): + # Force cleanup manually to debug singleton issue + EnhancedFeatureRepository._instances = {} + + custom_cache = CustomAsyncCache() + options = Options( + client_key="123", + api_host="http://localhost", + cache=custom_cache + ) + client = GrowthBookClient(options=options) + + # Debug info + print(f"DEBUG: Client cache option: {client.options.cache}") + print(f"DEBUG: FeatureRepo cache: {client._features_repository.cache}") + + # Ensure options passed correctly + assert client.options.cache is custom_cache + + assert client._features_repository.cache is custom_cache + + # Simulate loading features (mocking fetch would be better, but checking instance is good step 1) + # Let's try to set something manually in the cache and see if load_features finds it + key = "http://localhost::123" + features_data = {"features": {"foo": {"defaultValue": True}}} + await custom_cache.set(key, features_data, 60) + + # This should hit the cache and not fail due to network (since localhost might not be reachable) + # load_features_async is what we want to test + loaded = await client._features_repository.load_features_async("http://localhost", "123") + assert loaded == features_data + assert custom_cache.get_calls > 0 + + await client.close() diff --git a/tests/test_case_insensitive_operators.py b/tests/test_case_insensitive_operators.py new file mode 100644 index 0000000..1be8794 --- /dev/null +++ b/tests/test_case_insensitive_operators.py @@ -0,0 +1,222 @@ +"""Tests for case-insensitive membership operators: $ini, $nini, $alli""" +import pytest +from growthbook.core import evalCondition + + +class TestCaseInsensitiveOperators: + """Test case-insensitive membership operators""" + + def test_ini_pass_case_insensitive_match(self): + """$ini operator should match case-insensitively""" + condition = {"name": {"$ini": ["JOHN", "JANE"]}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition) is True + + def test_ini_pass_uppercase_value_lowercase_pattern(self): + """$ini operator should match with uppercase value and lowercase pattern""" + condition = {"name": {"$ini": ["john", "jane"]}} + attributes = {"name": "JOHN"} + assert evalCondition(attributes, condition) is True + + def test_ini_pass_mixed_case(self): + """$ini operator should match with mixed case""" + condition = {"name": {"$ini": ["JoHn", "jAnE"]}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition) is True + + def test_ini_fail(self): + """$ini operator should fail when value not in list""" + condition = {"name": {"$ini": ["JOHN", "JANE"]}} + attributes = {"name": "bob"} + assert evalCondition(attributes, condition) is False + + def test_ini_array_pass_1(self): + """$ini operator should work with array attributes - intersection""" + condition = {"tags": {"$ini": ["A", "B"]}} + attributes = {"tags": ["a", "c"]} + assert evalCondition(attributes, condition) is True + + def test_ini_array_pass_2(self): + """$ini operator should work with array attributes - multiple matches""" + condition = {"tags": {"$ini": ["A", "B"]}} + attributes = {"tags": ["a", "b"]} + assert evalCondition(attributes, condition) is True + + def test_ini_array_fail(self): + """$ini operator should fail when no intersection""" + condition = {"tags": {"$ini": ["A", "B"]}} + attributes = {"tags": ["c", "d"]} + assert evalCondition(attributes, condition) is False + + def test_ini_not_array(self): + """$ini operator should fail when condition value is not an array""" + condition = {"name": {"$ini": "JOHN"}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition) is False + + def test_nini_pass(self): + """$nini operator should pass when value not in list""" + condition = {"name": {"$nini": ["JOHN", "JANE"]}} + attributes = {"name": "bob"} + assert evalCondition(attributes, condition) is True + + def test_nini_fail_case_insensitive_match(self): + """$nini operator should fail on case-insensitive match""" + condition = {"name": {"$nini": ["JOHN", "JANE"]}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition) is False + + def test_nini_array_pass(self): + """$nini operator should pass with array when no intersection""" + condition = {"tags": {"$nini": ["A", "B"]}} + attributes = {"tags": ["c", "d"]} + assert evalCondition(attributes, condition) is True + + def test_nini_array_fail(self): + """$nini operator should fail with array when there's intersection""" + condition = {"tags": {"$nini": ["A", "B"]}} + attributes = {"tags": ["a", "c"]} + assert evalCondition(attributes, condition) is False + + def test_alli_pass(self): + """$alli operator should pass when all values match case-insensitively""" + condition = {"tags": {"$alli": ["A", "B"]}} + attributes = {"tags": ["a", "b", "c"]} + assert evalCondition(attributes, condition) is True + + def test_alli_pass_exact_match(self): + """$alli operator should pass with exact case-insensitive match""" + condition = {"tags": {"$alli": ["A", "B"]}} + attributes = {"tags": ["A", "B"]} + assert evalCondition(attributes, condition) is True + + def test_alli_fail_missing_value(self): + """$alli operator should fail when not all values present""" + condition = {"tags": {"$alli": ["A", "B", "C"]}} + attributes = {"tags": ["a", "b"]} + assert evalCondition(attributes, condition) is False + + def test_alli_fail_not_array(self): + """$alli operator should fail when attribute is not an array""" + condition = {"tags": {"$alli": ["A", "B"]}} + attributes = {"tags": "a"} + assert evalCondition(attributes, condition) is False + + def test_alli_pass_with_numbers(self): + """$alli operator should work with non-string values""" + condition = {"ids": {"$alli": [1, 2]}} + attributes = {"ids": [1, 2, 3]} + assert evalCondition(attributes, condition) is True + + def test_ini_pass_with_numbers(self): + """$ini operator should work with non-string values""" + condition = {"id": {"$ini": [1, 2, 3]}} + attributes = {"id": 2} + assert evalCondition(attributes, condition) is True + + def test_nini_pass_with_numbers(self): + """$nini operator should work with non-string values""" + condition = {"id": {"$nini": [1, 2, 3]}} + attributes = {"id": 4} + assert evalCondition(attributes, condition) is True + + def test_case_sensitive_in_vs_case_insensitive_ini(self): + """$in should be case-sensitive while $ini is case-insensitive""" + # $in should be case-sensitive (fail) + condition_in = {"name": {"$in": ["JOHN", "JANE"]}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition_in) is False + + # $ini should be case-insensitive (pass) + condition_ini = {"name": {"$ini": ["JOHN", "JANE"]}} + assert evalCondition(attributes, condition_ini) is True + + def test_case_sensitive_nin_vs_case_insensitive_nini(self): + """$nin should be case-sensitive while $nini is case-insensitive""" + # $nin should be case-sensitive (pass because "john" != "JOHN") + condition_nin = {"name": {"$nin": ["JOHN", "JANE"]}} + attributes = {"name": "john"} + assert evalCondition(attributes, condition_nin) is True + + # $nini should be case-insensitive (fail because "john" matches "JOHN") + condition_nini = {"name": {"$nini": ["JOHN", "JANE"]}} + assert evalCondition(attributes, condition_nini) is False + + def test_case_sensitive_all_vs_case_insensitive_alli(self): + """$all should be case-sensitive while $alli is case-insensitive""" + # $all should be case-sensitive (fail) + condition_all = {"tags": {"$all": ["A", "B"]}} + attributes = {"tags": ["a", "b", "c"]} + assert evalCondition(attributes, condition_all) is False + + # $alli should be case-insensitive (pass) + condition_alli = {"tags": {"$alli": ["A", "B"]}} + assert evalCondition(attributes, condition_alli) is True + + def test_complex_condition_with_ini(self): + """Complex condition with $ini and $or operators""" + condition = { + "$or": [ + {"country": {"$ini": ["USA", "CANADA"]}}, + {"language": {"$ini": ["EN", "FR"]}} + ] + } + + # Should match on country (case-insensitive) + attributes1 = {"country": "usa", "language": "de"} + assert evalCondition(attributes1, condition) is True + + # Should match on language (case-insensitive) + attributes2 = {"country": "germany", "language": "en"} + assert evalCondition(attributes2, condition) is True + + # Should not match + attributes3 = {"country": "germany", "language": "de"} + assert evalCondition(attributes3, condition) is False + + def test_complex_condition_with_alli(self): + """Complex condition with $alli and $and operators""" + condition = { + "$and": [ + {"tags": {"$alli": ["PREMIUM", "ACTIVE"]}}, + {"role": {"$ini": ["ADMIN", "MODERATOR"]}} + ] + } + + # Should match + attributes1 = {"tags": ["premium", "active", "verified"], "role": "admin"} + assert evalCondition(attributes1, condition) is True + + # Should not match (missing tag) + attributes2 = {"tags": ["premium"], "role": "admin"} + assert evalCondition(attributes2, condition) is False + + # Should not match (wrong role) + attributes3 = {"tags": ["premium", "active"], "role": "user"} + assert evalCondition(attributes3, condition) is False + + def test_empty_array_conditions(self): + """Test behavior with empty arrays""" + # Empty condition array should pass (vacuous truth) + condition_ini_empty = {"tags": {"$ini": []}} + attributes = {"tags": ["a", "b"]} + assert evalCondition(attributes, condition_ini_empty) is False + + # Empty attribute array should fail for $alli + condition_alli = {"tags": {"$alli": ["A", "B"]}} + attributes_empty = {"tags": []} + assert evalCondition(attributes_empty, condition_alli) is False + + def test_unicode_case_insensitive(self): + """Test case-insensitive matching with unicode characters""" + condition = {"name": {"$ini": ["CAFÉ", "RÉSUMÉ"]}} + attributes = {"name": "café"} + assert evalCondition(attributes, condition) is True + + condition2 = {"name": {"$ini": ["café", "résumé"]}} + attributes2 = {"name": "CAFÉ"} + assert evalCondition(attributes2, condition2) is True + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_growthbook_client.py b/tests/test_growthbook_client.py index c88b024..2b2437c 100644 --- a/tests/test_growthbook_client.py +++ b/tests/test_growthbook_client.py @@ -116,7 +116,7 @@ async def test_feature_repository_load(): "savedGroups": {} } - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.FeatureRepository._fetch_features_async', new_callable=AsyncMock, return_value=features_response) as mock_load: result = await repo.load_features_async(api_host="", client_key="") assert result == features_response @@ -291,7 +291,7 @@ async def test_http_refresh(): mock_load.side_effect = [feature_updates[0], feature_updates[1], *[feature_updates[1]] * 10] try: - with patch('growthbook.FeatureRepository.load_features_async', mock_load): + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', mock_load): # Start HTTP refresh with a short interval for testing refresh_task = asyncio.create_task(repo._start_http_refresh(interval=0.1)) @@ -321,7 +321,7 @@ async def test_callback(features): callback_called = True features_received = features - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=mock_features_response) as mock_load: client = GrowthBookClient(mock_options) @@ -350,7 +350,12 @@ async def test_sse_event_handling(mock_options): {'type': 'features', 'data': json.dumps({'features': {'feature1': {'defaultValue': 2}}})} ] - with patch('growthbook.FeatureRepository.load_features_async', + async def mock_sse_handler(event_data): + """Mock the SSE event handler to directly update feature cache""" + if event_data['type'] == 'features': + await client._features_repository._handle_feature_update(event_data['data']) + + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value={"features": {}, "savedGroups": {}}) as mock_load: # Create options with SSE strategy @@ -407,7 +412,7 @@ async def mock_load(*args, **kwargs): return {"features": {}, "savedGroups": {}} try: - with patch('growthbook.FeatureRepository.load_features_async', side_effect=mock_load): + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', side_effect=mock_load): refresh_task = asyncio.create_task(repo._start_http_refresh(interval=0.1)) try: await asyncio.wait_for(done.wait(), timeout=5.0) @@ -457,7 +462,7 @@ async def mock_load(*args, **kwargs): shared_response["features"]["test-feature"]["defaultValue"] += 1 return shared_response - with patch('growthbook.FeatureRepository.load_features_async', side_effect=mock_load): + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', side_effect=mock_load): client = GrowthBookClient(Options( api_host="https://test.growthbook.io", client_key="test_key" @@ -529,7 +534,7 @@ async def test_eval_feature(test_eval_feature_data, base_client_setup): try: # Set up mocks for both FeatureRepository and EnhancedFeatureRepository - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=features_data), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -564,7 +569,7 @@ async def test_experiment_run(test_experiment_run_data, base_client_setup): try: # Set up mocks for both FeatureRepository and EnhancedFeatureRepository - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=features_data), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -611,7 +616,7 @@ async def test_feature_methods(): try: # Set up mocks for both FeatureRepository and EnhancedFeatureRepository - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=features_data), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -704,7 +709,7 @@ async def test_sticky_bucket(test_sticky_bucket_data, base_client_setup): try: # Set up mocks - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=features_data), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -770,7 +775,7 @@ async def test_tracking(): try: # Set up mocks for feature repository - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value={"features": {}, "savedGroups": {}}), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -831,7 +836,7 @@ async def test_handles_tracking_errors(): try: # Set up mocks - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value={"features": {}, "savedGroups": {}}), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -884,7 +889,7 @@ def feature_usage_cb(key, result, user_context): "savedGroups": {} } - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=mock_features), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -957,7 +962,7 @@ def failing_callback(key, result, user_context): "savedGroups": {} } - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=mock_features), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \ @@ -1019,7 +1024,7 @@ async def test_skip_all_experiments_flag(): "savedGroups": {} } - with patch('growthbook.FeatureRepository.load_features_async', + with patch('growthbook.growthbook_client.EnhancedFeatureRepository.load_features_async', new_callable=AsyncMock, return_value=mock_features), \ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh', new_callable=AsyncMock), \