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.
Files changed (27) hide show
  1. {growthbook-2.1.0/growthbook.egg-info → growthbook-2.1.2}/PKG-INFO +1 -1
  2. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/__init__.py +1 -1
  3. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/core.py +51 -11
  4. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/growthbook_client.py +2 -2
  5. {growthbook-2.1.0 → growthbook-2.1.2/growthbook.egg-info}/PKG-INFO +1 -1
  6. {growthbook-2.1.0 → growthbook-2.1.2}/setup.cfg +1 -1
  7. {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_growthbook_client.py +79 -5
  8. {growthbook-2.1.0 → growthbook-2.1.2}/LICENSE +0 -0
  9. {growthbook-2.1.0 → growthbook-2.1.2}/MANIFEST.in +0 -0
  10. {growthbook-2.1.0 → growthbook-2.1.2}/README.md +0 -0
  11. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/common_types.py +0 -0
  12. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/growthbook.py +0 -0
  13. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/__init__.py +0 -0
  14. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/base.py +0 -0
  15. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/growthbook_tracking.py +0 -0
  16. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/plugins/request_context.py +0 -0
  17. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook/py.typed +0 -0
  18. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/SOURCES.txt +0 -0
  19. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/dependency_links.txt +0 -0
  20. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/requires.txt +0 -0
  21. {growthbook-2.1.0 → growthbook-2.1.2}/growthbook.egg-info/top_level.txt +0 -0
  22. {growthbook-2.1.0 → growthbook-2.1.2}/pyproject.toml +0 -0
  23. {growthbook-2.1.0 → growthbook-2.1.2}/setup.py +0 -0
  24. {growthbook-2.1.0 → growthbook-2.1.2}/tests/conftest.py +0 -0
  25. {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_etag.py +0 -0
  26. {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_growthbook.py +0 -0
  27. {growthbook-2.1.0 → growthbook-2.1.2}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "2.1.0"
21
+ __version__ = "2.1.2"
22
22
  # x-release-please-end
@@ -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 (type(attributeValue) is list):
227
+ if not type(conditionValue) is list:
215
228
  return False
216
- for cond in conditionValue:
217
- passing = False
218
- for attr in attributeValue:
219
- if evalConditionValue(cond, attr, savedGroups):
220
- passing = True
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'].update(features)
94
- self._cache['savedGroups'].update(saved_groups)
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"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.1.0
2
+ current_version = 2.1.2
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -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"] == 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