growthbook 2.1.0__tar.gz → 2.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {growthbook-2.1.0/growthbook.egg-info → growthbook-2.1.2}/PKG-INFO +1 -1
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/__init__.py +1 -1
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/core.py +51 -11
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/growthbook_client.py +2 -2
- {growthbook-2.1.0 → growthbook-2.1.2/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-2.1.0 → growthbook-2.1.2}/setup.cfg +1 -1
- {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_growthbook_client.py +79 -5
- {growthbook-2.1.0 → growthbook-2.1.2}/LICENSE +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/MANIFEST.in +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/README.md +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/common_types.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/growthbook.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/__init__.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/base.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/request_context.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/py.typed +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/pyproject.toml +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/setup.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/tests/conftest.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_etag.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_growthbook.py +0 -0
- {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_plugins.py +0 -0
|
@@ -76,12 +76,17 @@ def getPath(attributes, path):
|
|
|
76
76
|
return None
|
|
77
77
|
return current
|
|
78
78
|
|
|
79
|
-
def evalConditionValue(conditionValue, attributeValue, savedGroups) -> bool:
|
|
79
|
+
def evalConditionValue(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
|
|
80
80
|
if type(conditionValue) is dict and isOperatorObject(conditionValue):
|
|
81
81
|
for key, value in conditionValue.items():
|
|
82
82
|
if not evalOperatorCondition(key, attributeValue, value, savedGroups):
|
|
83
83
|
return False
|
|
84
84
|
return True
|
|
85
|
+
|
|
86
|
+
# Simple equality comparison with optional case-insensitivity
|
|
87
|
+
if insensitive and type(conditionValue) is str and type(attributeValue) is str:
|
|
88
|
+
return conditionValue.lower() == attributeValue.lower()
|
|
89
|
+
|
|
85
90
|
return bool(conditionValue == attributeValue)
|
|
86
91
|
|
|
87
92
|
def elemMatch(condition, attributeValue, savedGroups) -> bool:
|
|
@@ -204,6 +209,14 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
|
|
|
204
209
|
if not type(conditionValue) is list:
|
|
205
210
|
return False
|
|
206
211
|
return not isIn(conditionValue, attributeValue)
|
|
212
|
+
elif operator == "$ini":
|
|
213
|
+
if not type(conditionValue) is list:
|
|
214
|
+
return False
|
|
215
|
+
return isIn(conditionValue, attributeValue, insensitive=True)
|
|
216
|
+
elif operator == "$nini":
|
|
217
|
+
if not type(conditionValue) is list:
|
|
218
|
+
return False
|
|
219
|
+
return not isIn(conditionValue, attributeValue, insensitive=True)
|
|
207
220
|
elif operator == "$elemMatch":
|
|
208
221
|
return elemMatch(conditionValue, attributeValue, savedGroups)
|
|
209
222
|
elif operator == "$size":
|
|
@@ -211,16 +224,13 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
|
|
|
211
224
|
return False
|
|
212
225
|
return evalConditionValue(conditionValue, len(attributeValue), savedGroups)
|
|
213
226
|
elif operator == "$all":
|
|
214
|
-
if not
|
|
227
|
+
if not type(conditionValue) is list:
|
|
215
228
|
return False
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if not passing:
|
|
222
|
-
return False
|
|
223
|
-
return True
|
|
229
|
+
return isInAll(conditionValue, attributeValue, savedGroups, insensitive=False)
|
|
230
|
+
elif operator == "$alli":
|
|
231
|
+
if not type(conditionValue) is list:
|
|
232
|
+
return False
|
|
233
|
+
return isInAll(conditionValue, attributeValue, savedGroups, insensitive=True)
|
|
224
234
|
elif operator == "$exists":
|
|
225
235
|
if not conditionValue:
|
|
226
236
|
return attributeValue is None
|
|
@@ -254,11 +264,41 @@ def paddedVersionString(input) -> str:
|
|
|
254
264
|
return "-".join([v.rjust(5, " ") if re.match(r"^[0-9]+$", v) else v for v in parts])
|
|
255
265
|
|
|
256
266
|
|
|
257
|
-
def isIn(conditionValue, attributeValue) -> bool:
|
|
267
|
+
def isIn(conditionValue, attributeValue, insensitive: bool = False) -> bool:
|
|
268
|
+
if insensitive:
|
|
269
|
+
# Helper function to case-fold values (lowercase for strings)
|
|
270
|
+
def case_fold(val):
|
|
271
|
+
return val.lower() if type(val) is str else val
|
|
272
|
+
|
|
273
|
+
# Do an intersection if attribute is an array (insensitive)
|
|
274
|
+
if type(attributeValue) is list:
|
|
275
|
+
return any(
|
|
276
|
+
case_fold(el) == case_fold(exp)
|
|
277
|
+
for el in attributeValue
|
|
278
|
+
for exp in conditionValue
|
|
279
|
+
)
|
|
280
|
+
return any(case_fold(attributeValue) == case_fold(exp) for exp in conditionValue)
|
|
281
|
+
|
|
282
|
+
# Case-sensitive behavior (original)
|
|
258
283
|
if type(attributeValue) is list:
|
|
259
284
|
return bool(set(conditionValue) & set(attributeValue))
|
|
260
285
|
return attributeValue in conditionValue
|
|
261
286
|
|
|
287
|
+
def isInAll(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
|
|
288
|
+
"""Check if attributeValue (array) contains all elements in conditionValue"""
|
|
289
|
+
if not type(attributeValue) is list:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
for cond in conditionValue:
|
|
293
|
+
passing = False
|
|
294
|
+
for attr in attributeValue:
|
|
295
|
+
if evalConditionValue(cond, attr, savedGroups, insensitive):
|
|
296
|
+
passing = True
|
|
297
|
+
break
|
|
298
|
+
if not passing:
|
|
299
|
+
return False
|
|
300
|
+
return True
|
|
301
|
+
|
|
262
302
|
def _getOrigHashValue(
|
|
263
303
|
eval_context: EvaluationContext,
|
|
264
304
|
attr: Optional[str] = "id",
|
|
@@ -90,8 +90,8 @@ class FeatureCache:
|
|
|
90
90
|
def update(self, features: Dict[str, Any], saved_groups: Dict[str, Any]) -> None:
|
|
91
91
|
"""Simple thread-safe update of cache with new API data"""
|
|
92
92
|
with self._lock:
|
|
93
|
-
self._cache['features']
|
|
94
|
-
self._cache['savedGroups']
|
|
93
|
+
self._cache['features'] = dict(features)
|
|
94
|
+
self._cache['savedGroups'] = dict(saved_groups)
|
|
95
95
|
|
|
96
96
|
def get_current_state(self) -> Dict[str, Any]:
|
|
97
97
|
"""Get current cache state"""
|
|
@@ -158,19 +158,93 @@ async def test_concurrent_feature_updates():
|
|
|
158
158
|
client_key="test_key"
|
|
159
159
|
)
|
|
160
160
|
features = {f"feature-{i}": {"defaultValue": i} for i in range(10)}
|
|
161
|
-
|
|
161
|
+
|
|
162
162
|
async def update_features(feature_subset):
|
|
163
163
|
await repo._handle_feature_update({"features": feature_subset, "savedGroups": {}})
|
|
164
|
-
|
|
164
|
+
|
|
165
165
|
await asyncio.gather(*[
|
|
166
|
-
update_features({k: features[k]})
|
|
166
|
+
update_features({k: features[k]})
|
|
167
167
|
for k in features
|
|
168
168
|
])
|
|
169
|
-
|
|
169
|
+
|
|
170
170
|
cache_state = repo._feature_cache.get_current_state()
|
|
171
171
|
# Verify all features were properly stored
|
|
172
|
-
assert cache_state["features"] ==
|
|
172
|
+
assert len(cache_state["features"]) == 1
|
|
173
173
|
assert cache_state["savedGroups"] == {}
|
|
174
|
+
feature_key = list(cache_state["features"].keys())[0]
|
|
175
|
+
assert feature_key in features
|
|
176
|
+
assert cache_state["features"][feature_key] == features[feature_key]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_feature_cache_thread_safety(cache):
|
|
181
|
+
"""Verify FeatureCache is thread-safe during concurrent updates"""
|
|
182
|
+
repo = EnhancedFeatureRepository(
|
|
183
|
+
api_host="https://test.growthbook.io",
|
|
184
|
+
client_key="test_key"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
feature_sets = [
|
|
188
|
+
{f"set-{i}-feature-{j}": {"value": j} for j in range(3)}
|
|
189
|
+
for i in range(5)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
async def update_full_set(feature_set):
|
|
193
|
+
await repo._handle_feature_update({
|
|
194
|
+
"features": feature_set,
|
|
195
|
+
"savedGroups": {}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
# Concurrent updates
|
|
199
|
+
await asyncio.gather(*[update_full_set(fs) for fs in feature_sets])
|
|
200
|
+
|
|
201
|
+
cache = repo._feature_cache.get_current_state()
|
|
202
|
+
|
|
203
|
+
# One complete set should be in cache (race condition winner)
|
|
204
|
+
assert len(cache["features"]) == 3
|
|
205
|
+
cache_keys = set(cache["features"].keys())
|
|
206
|
+
assert any(cache_keys == set(fs.keys()) for fs in feature_sets)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.asyncio
|
|
210
|
+
async def test_disabled_features_removed_from_cache(cache):
|
|
211
|
+
"""
|
|
212
|
+
Regression test: disabled features must be removed from cache.
|
|
213
|
+
|
|
214
|
+
Previously, FeatureCache.update() used dict.update() which only
|
|
215
|
+
adds/modifies entries but never removes them. This caused disabled
|
|
216
|
+
features to persist in the cache indefinitely.
|
|
217
|
+
"""
|
|
218
|
+
repo = EnhancedFeatureRepository(
|
|
219
|
+
api_host="https://test.growthbook.io",
|
|
220
|
+
client_key="test_key"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Initial state: 2 features enabled
|
|
224
|
+
await repo._handle_feature_update({
|
|
225
|
+
"features": {
|
|
226
|
+
"feature-a": {"defaultValue": True},
|
|
227
|
+
"feature-b": {"defaultValue": False}
|
|
228
|
+
},
|
|
229
|
+
"savedGroups": {}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
cache = repo._feature_cache.get_current_state()
|
|
233
|
+
assert "feature-a" in cache["features"]
|
|
234
|
+
assert "feature-b" in cache["features"]
|
|
235
|
+
|
|
236
|
+
# User disables feature-b in Growthbook UI
|
|
237
|
+
# API now returns only active features
|
|
238
|
+
await repo._handle_feature_update({
|
|
239
|
+
"features": {
|
|
240
|
+
"feature-a": {"defaultValue": True}
|
|
241
|
+
},
|
|
242
|
+
"savedGroups": {}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
cache = repo._feature_cache.get_current_state()
|
|
246
|
+
assert "feature-a" in cache["features"]
|
|
247
|
+
assert "feature-b" not in cache["features"] # Must be removed!
|
|
174
248
|
|
|
175
249
|
@pytest.mark.asyncio
|
|
176
250
|
async def test_callback_thread_safety():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|