growthbook 2.1.1__tar.gz → 2.1.3__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 (28) hide show
  1. {growthbook-2.1.1/growthbook.egg-info → growthbook-2.1.3}/PKG-INFO +1 -1
  2. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/__init__.py +1 -1
  3. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/core.py +30 -29
  4. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/growthbook_client.py +2 -2
  5. {growthbook-2.1.1 → growthbook-2.1.3/growthbook.egg-info}/PKG-INFO +1 -1
  6. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook.egg-info/SOURCES.txt +1 -0
  7. {growthbook-2.1.1 → growthbook-2.1.3}/setup.cfg +1 -1
  8. growthbook-2.1.3/tests/test_dict_subclass.py +33 -0
  9. {growthbook-2.1.1 → growthbook-2.1.3}/tests/test_growthbook_client.py +79 -5
  10. {growthbook-2.1.1 → growthbook-2.1.3}/LICENSE +0 -0
  11. {growthbook-2.1.1 → growthbook-2.1.3}/MANIFEST.in +0 -0
  12. {growthbook-2.1.1 → growthbook-2.1.3}/README.md +0 -0
  13. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/common_types.py +0 -0
  14. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/growthbook.py +0 -0
  15. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/plugins/__init__.py +0 -0
  16. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/plugins/base.py +0 -0
  17. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/plugins/growthbook_tracking.py +0 -0
  18. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/plugins/request_context.py +0 -0
  19. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook/py.typed +0 -0
  20. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook.egg-info/dependency_links.txt +0 -0
  21. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook.egg-info/requires.txt +0 -0
  22. {growthbook-2.1.1 → growthbook-2.1.3}/growthbook.egg-info/top_level.txt +0 -0
  23. {growthbook-2.1.1 → growthbook-2.1.3}/pyproject.toml +0 -0
  24. {growthbook-2.1.1 → growthbook-2.1.3}/setup.py +0 -0
  25. {growthbook-2.1.1 → growthbook-2.1.3}/tests/conftest.py +0 -0
  26. {growthbook-2.1.1 → growthbook-2.1.3}/tests/test_etag.py +0 -0
  27. {growthbook-2.1.1 → growthbook-2.1.3}/tests/test_growthbook.py +0 -0
  28. {growthbook-2.1.1 → growthbook-2.1.3}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 2.1.1
3
+ Version: 2.1.3
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.1"
21
+ __version__ = "2.1.3"
22
22
  # x-release-please-end
@@ -50,47 +50,48 @@ def isOperatorObject(obj: Any) -> bool:
50
50
  return False
51
51
  return True
52
52
 
53
- def getType(attributeValue) -> str:
54
- t = type(attributeValue)
53
+ def _is_numeric(v: Any) -> bool:
54
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
55
55
 
56
+ def getType(attributeValue) -> str:
56
57
  if attributeValue is None:
57
58
  return "null"
58
- if t is int or t is float:
59
+ if isinstance(attributeValue, bool):
60
+ return "boolean"
61
+ if _is_numeric(attributeValue):
59
62
  return "number"
60
- if t is str:
63
+ if isinstance(attributeValue, str):
61
64
  return "string"
62
- if t is list or t is set:
65
+ if isinstance(attributeValue, (list, set)):
63
66
  return "array"
64
- if t is dict:
67
+ if isinstance(attributeValue, dict):
65
68
  return "object"
66
- if t is bool:
67
- return "boolean"
68
69
  return "unknown"
69
70
 
70
71
  def getPath(attributes, path):
71
72
  current = attributes
72
73
  for segment in path.split("."):
73
- if type(current) is dict and segment in current:
74
+ if isinstance(current, dict) and segment in current:
74
75
  current = current[segment]
75
76
  else:
76
77
  return None
77
78
  return current
78
79
 
79
80
  def evalConditionValue(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
80
- if type(conditionValue) is dict and isOperatorObject(conditionValue):
81
+ if isinstance(conditionValue, dict) and isOperatorObject(conditionValue):
81
82
  for key, value in conditionValue.items():
82
83
  if not evalOperatorCondition(key, attributeValue, value, savedGroups):
83
84
  return False
84
85
  return True
85
86
 
86
87
  # Simple equality comparison with optional case-insensitivity
87
- if insensitive and type(conditionValue) is str and type(attributeValue) is str:
88
+ if insensitive and isinstance(conditionValue, str) and isinstance(attributeValue, str):
88
89
  return conditionValue.lower() == attributeValue.lower()
89
90
 
90
91
  return bool(conditionValue == attributeValue)
91
92
 
92
93
  def elemMatch(condition, attributeValue, savedGroups) -> bool:
93
- if not type(attributeValue) is list:
94
+ if not isinstance(attributeValue, list):
94
95
  return False
95
96
 
96
97
  for item in attributeValue:
@@ -104,13 +105,13 @@ def elemMatch(condition, attributeValue, savedGroups) -> bool:
104
105
  return False
105
106
 
106
107
  def compare(val1, val2) -> int:
107
- if (type(val1) is int or type(val1) is float) and not (type(val2) is int or type(val2) is float):
108
+ if _is_numeric(val1) and not _is_numeric(val2):
108
109
  if (val2 is None):
109
110
  val2 = 0
110
111
  else:
111
112
  val2 = float(val2)
112
113
 
113
- if (type(val2) is int or type(val2) is float) and not (type(val1) is int or type(val1) is float):
114
+ if _is_numeric(val2) and not _is_numeric(val1):
114
115
  if (val1 is None):
115
116
  val1 = 0
116
117
  else:
@@ -166,13 +167,13 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
166
167
  elif operator == "$vgte":
167
168
  return paddedVersionString(attributeValue) >= paddedVersionString(conditionValue)
168
169
  elif operator == "$inGroup":
169
- if not type(conditionValue) is str:
170
+ if not isinstance(conditionValue, str):
170
171
  return False
171
172
  if not conditionValue in savedGroups:
172
173
  return False
173
174
  return isIn(savedGroups[conditionValue] or [], attributeValue)
174
175
  elif operator == "$notInGroup":
175
- if not type(conditionValue) is str:
176
+ if not isinstance(conditionValue, str):
176
177
  return False
177
178
  if not conditionValue in savedGroups:
178
179
  return True
@@ -202,33 +203,33 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
202
203
  except Exception:
203
204
  return False
204
205
  elif operator == "$in":
205
- if not type(conditionValue) is list:
206
+ if not isinstance(conditionValue, list):
206
207
  return False
207
208
  return isIn(conditionValue, attributeValue)
208
209
  elif operator == "$nin":
209
- if not type(conditionValue) is list:
210
+ if not isinstance(conditionValue, list):
210
211
  return False
211
212
  return not isIn(conditionValue, attributeValue)
212
213
  elif operator == "$ini":
213
- if not type(conditionValue) is list:
214
+ if not isinstance(conditionValue, list):
214
215
  return False
215
216
  return isIn(conditionValue, attributeValue, insensitive=True)
216
217
  elif operator == "$nini":
217
- if not type(conditionValue) is list:
218
+ if not isinstance(conditionValue, list):
218
219
  return False
219
220
  return not isIn(conditionValue, attributeValue, insensitive=True)
220
221
  elif operator == "$elemMatch":
221
222
  return elemMatch(conditionValue, attributeValue, savedGroups)
222
223
  elif operator == "$size":
223
- if not (type(attributeValue) is list):
224
+ if not isinstance(attributeValue, list):
224
225
  return False
225
226
  return evalConditionValue(conditionValue, len(attributeValue), savedGroups)
226
227
  elif operator == "$all":
227
- if not type(conditionValue) is list:
228
+ if not isinstance(conditionValue, list):
228
229
  return False
229
230
  return isInAll(conditionValue, attributeValue, savedGroups, insensitive=False)
230
231
  elif operator == "$alli":
231
- if not type(conditionValue) is list:
232
+ if not isinstance(conditionValue, list):
232
233
  return False
233
234
  return isInAll(conditionValue, attributeValue, savedGroups, insensitive=True)
234
235
  elif operator == "$exists":
@@ -243,10 +244,10 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
243
244
 
244
245
  def paddedVersionString(input) -> str:
245
246
  # If input is a number, convert to a string
246
- if type(input) is int or type(input) is float:
247
+ if _is_numeric(input):
247
248
  input = str(input)
248
249
 
249
- if not input or type(input) is not str:
250
+ if not input or not isinstance(input, str):
250
251
  input = "0"
251
252
 
252
253
  # Remove build info and leading `v` if any
@@ -268,10 +269,10 @@ def isIn(conditionValue, attributeValue, insensitive: bool = False) -> bool:
268
269
  if insensitive:
269
270
  # Helper function to case-fold values (lowercase for strings)
270
271
  def case_fold(val):
271
- return val.lower() if type(val) is str else val
272
+ return val.lower() if isinstance(val, str) else val
272
273
 
273
274
  # Do an intersection if attribute is an array (insensitive)
274
- if type(attributeValue) is list:
275
+ if isinstance(attributeValue, list):
275
276
  return any(
276
277
  case_fold(el) == case_fold(exp)
277
278
  for el in attributeValue
@@ -280,13 +281,13 @@ def isIn(conditionValue, attributeValue, insensitive: bool = False) -> bool:
280
281
  return any(case_fold(attributeValue) == case_fold(exp) for exp in conditionValue)
281
282
 
282
283
  # Case-sensitive behavior (original)
283
- if type(attributeValue) is list:
284
+ if isinstance(attributeValue, list):
284
285
  return bool(set(conditionValue) & set(attributeValue))
285
286
  return attributeValue in conditionValue
286
287
 
287
288
  def isInAll(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
288
289
  """Check if attributeValue (array) contains all elements in conditionValue"""
289
- if not type(attributeValue) is list:
290
+ if not isinstance(attributeValue, list):
290
291
  return False
291
292
 
292
293
  for cond in conditionValue:
@@ -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.1
3
+ Version: 2.1.3
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
@@ -20,6 +20,7 @@ growthbook/plugins/base.py
20
20
  growthbook/plugins/growthbook_tracking.py
21
21
  growthbook/plugins/request_context.py
22
22
  tests/conftest.py
23
+ tests/test_dict_subclass.py
23
24
  tests/test_etag.py
24
25
  tests/test_growthbook.py
25
26
  tests/test_growthbook_client.py
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.1.1
2
+ current_version = 2.1.3
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -0,0 +1,33 @@
1
+ import unittest
2
+ from growthbook.core import getPath, evalCondition
3
+
4
+ class MyDict(dict):
5
+ pass
6
+
7
+ class TestDictSubclass(unittest.TestCase):
8
+ def test_get_path_with_subclass(self):
9
+ # Test getPath with a dict subclass
10
+ attributes = MyDict({"user": MyDict({"id": "123", "name": "John"})})
11
+
12
+ self.assertEqual(getPath(attributes, "user.id"), "123")
13
+ self.assertEqual(getPath(attributes, "user.name"), "John")
14
+ self.assertEqual(getPath(attributes, "user.nonexistent"), None)
15
+
16
+ def test_eval_condition_with_subclass(self):
17
+ # Test evalCondition with a dict subclass
18
+ attributes = MyDict({"company": "GrowthBook", "meta": MyDict({"plan": "pro"})})
19
+
20
+ # Simple condition
21
+ condition = {"company": "GrowthBook"}
22
+ self.assertTrue(evalCondition(attributes, condition))
23
+
24
+ # Nested condition using getPath (indirectly)
25
+ condition = {"meta.plan": "pro"}
26
+ self.assertTrue(evalCondition(attributes, condition))
27
+
28
+ # Condition failing
29
+ condition = {"meta.plan": "free"}
30
+ self.assertFalse(evalCondition(attributes, condition))
31
+
32
+ if __name__ == '__main__':
33
+ unittest.main()
@@ -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