limits 5.0.0rc2__tar.gz → 5.2.0__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 (96) hide show
  1. {limits-5.0.0rc2 → limits-5.2.0}/HISTORY.rst +27 -14
  2. {limits-5.0.0rc2 → limits-5.2.0}/PKG-INFO +2 -2
  3. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/theme_config.py +0 -1
  4. {limits-5.0.0rc2 → limits-5.2.0}/limits/_version.py +3 -3
  5. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/base.py +15 -1
  6. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/__init__.py +6 -0
  7. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memory.py +6 -0
  8. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/mongodb.py +3 -0
  9. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/__init__.py +29 -6
  10. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/bridge.py +3 -2
  11. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/strategies.py +5 -0
  12. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/base.py +15 -1
  13. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/memcached.py +6 -0
  14. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/memory.py +6 -0
  15. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/mongodb.py +3 -0
  16. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis.py +11 -2
  17. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis_cluster.py +5 -2
  18. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis_sentinel.py +3 -0
  19. {limits-5.0.0rc2 → limits-5.2.0}/limits/strategies.py +5 -0
  20. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/PKG-INFO +2 -2
  21. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/requires.txt +1 -1
  22. {limits-5.0.0rc2 → limits-5.2.0}/requirements/main.txt +1 -1
  23. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_storage.py +12 -0
  24. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_strategy.py +3 -0
  25. {limits-5.0.0rc2 → limits-5.2.0}/CLASSIFIERS +0 -0
  26. {limits-5.0.0rc2 → limits-5.2.0}/CONTRIBUTIONS.rst +0 -0
  27. {limits-5.0.0rc2 → limits-5.2.0}/LICENSE.txt +0 -0
  28. {limits-5.0.0rc2 → limits-5.2.0}/MANIFEST.in +0 -0
  29. {limits-5.0.0rc2 → limits-5.2.0}/README.rst +0 -0
  30. {limits-5.0.0rc2 → limits-5.2.0}/doc/Makefile +0 -0
  31. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/_static/custom.css +0 -0
  32. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/api.rst +0 -0
  33. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/async.rst +0 -0
  34. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/changelog.rst +0 -0
  35. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/conf.py +0 -0
  36. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/custom-storage.rst +0 -0
  37. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/benchmark-chart.css +0 -0
  38. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-chart.js +0 -0
  39. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-details.js +0 -0
  40. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-loader.js +0 -0
  41. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_templates/git_info.js +0 -0
  42. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/bench_chart.py +0 -0
  43. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/index.rst +0 -0
  44. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/installation.rst +0 -0
  45. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/performance.rst +0 -0
  46. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/quickstart.rst +0 -0
  47. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/storage.rst +0 -0
  48. {limits-5.0.0rc2 → limits-5.2.0}/doc/source/strategies.rst +0 -0
  49. {limits-5.0.0rc2 → limits-5.2.0}/limits/__init__.py +0 -0
  50. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/__init__.py +0 -0
  51. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/__init__.py +0 -0
  52. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/bridge.py +0 -0
  53. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/emcache.py +0 -0
  54. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/memcachio.py +0 -0
  55. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/coredis.py +0 -0
  56. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/redispy.py +0 -0
  57. {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/valkey.py +0 -0
  58. {limits-5.0.0rc2 → limits-5.2.0}/limits/errors.py +0 -0
  59. {limits-5.0.0rc2 → limits-5.2.0}/limits/limits.py +0 -0
  60. {limits-5.0.0rc2 → limits-5.2.0}/limits/py.typed +0 -0
  61. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_moving_window.lua +0 -0
  62. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_sliding_window.lua +0 -0
  63. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/clear_keys.lua +0 -0
  64. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/incr_expire.lua +0 -0
  65. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/moving_window.lua +0 -0
  66. {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/sliding_window.lua +0 -0
  67. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/__init__.py +0 -0
  68. {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/registry.py +0 -0
  69. {limits-5.0.0rc2 → limits-5.2.0}/limits/typing.py +0 -0
  70. {limits-5.0.0rc2 → limits-5.2.0}/limits/util.py +0 -0
  71. {limits-5.0.0rc2 → limits-5.2.0}/limits/version.py +0 -0
  72. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/SOURCES.txt +0 -0
  73. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/dependency_links.txt +0 -0
  74. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/not-zip-safe +0 -0
  75. {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/top_level.txt +0 -0
  76. {limits-5.0.0rc2 → limits-5.2.0}/pyproject.toml +0 -0
  77. {limits-5.0.0rc2 → limits-5.2.0}/requirements/ci.txt +0 -0
  78. {limits-5.0.0rc2 → limits-5.2.0}/requirements/dev.txt +0 -0
  79. {limits-5.0.0rc2 → limits-5.2.0}/requirements/docs.txt +0 -0
  80. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-memcached.txt +0 -0
  81. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-mongodb.txt +0 -0
  82. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-redis.txt +0 -0
  83. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-valkey.txt +0 -0
  84. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/memcached.txt +0 -0
  85. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/mongodb.txt +0 -0
  86. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/redis.txt +0 -0
  87. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/rediscluster.txt +0 -0
  88. {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/valkey.txt +0 -0
  89. {limits-5.0.0rc2 → limits-5.2.0}/requirements/test.txt +0 -0
  90. {limits-5.0.0rc2 → limits-5.2.0}/setup.cfg +0 -0
  91. {limits-5.0.0rc2 → limits-5.2.0}/setup.py +0 -0
  92. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_limit_granularities.py +0 -0
  93. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_limits.py +0 -0
  94. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_ratelimit_parser.py +0 -0
  95. {limits-5.0.0rc2 → limits-5.2.0}/tests/test_utils.py +0 -0
  96. {limits-5.0.0rc2 → limits-5.2.0}/versioneer.py +0 -0
@@ -3,35 +3,45 @@
3
3
  Changelog
4
4
  =========
5
5
 
6
- v5.0.0rc2
7
- ---------
8
- Release Date: 2025-04-15
6
+ v5.2.0
7
+ ------
8
+ Release Date: 2025-05-16
9
9
 
10
- * Compatibility
10
+ * Bug Fix
11
11
 
12
- * Add back emcache as a non default implementation for memcached + asyncio
13
- * Remove support for memcached < 1.5
12
+ * Fix incorrect behavior of the ``clear`` method for sliding window
13
+ counter which effectively did not clear the sliding window for
14
+ redis, memcached & in memory storage implementations.
15
+ `Issue 276 <https://github.com/alisaifee/limits/issues/276>`_
14
16
 
15
- * Documentation
17
+ v5.1.0
18
+ ------
19
+ Release Date: 2025-04-23
16
20
 
17
- * Improve presentation of benchmark documentation
21
+ * Features
18
22
 
23
+ * Expose ``key_prefix`` constructor argument for all redis storage
24
+ implementations to simplify customizing the prefix used for all
25
+ keys created in redis.
19
26
 
20
- v5.0.0rc1
21
- ---------
22
- Release Date: 2025-04-09
27
+ v5.0.0
28
+ ------
29
+ Release Date: 2025-04-15
23
30
 
24
31
  * Backward incompatible changes
25
32
 
26
33
  * Dropped support for Fixed Window with Elastic Expiry strategy
27
34
  * Dropped support for etcd
28
- * Replaced async support for memached from :pypi:`emcache` to :pypi`:memcachio`
35
+ * Changed the default implementation for async+memached from :pypi:`emcache`
36
+ to :pypi`:memcachio`
29
37
 
30
38
  * Performance
31
39
 
32
- * Improved performance of in-memory moving window ``test`` and ``get_window_stats`` operations.
33
- * Improved performance of redis moving window ``test`` and ``get_window_stats`` operations.
40
+ * Improved performance of redis moving window ``test`` and ``get_window_stats`` operations
41
+ especially when dealing with large rate limits.
34
42
  * Improved performance of mongodb moving window ``test`` and ``get_window_stats`` operations.
43
+ * Improved performance of in-memory moving window ``test`` and ``get_window_stats`` operations.
44
+ * Reduced load on event loop when expiring limits with async in-memory implementations
35
45
 
36
46
  v4.7.3
37
47
  ------
@@ -835,3 +845,6 @@ Release Date: 2015-01-08
835
845
 
836
846
  * Initial import of common rate limiting code from `Flask-Limiter <https://github.com/alisaifee/flask-limiter>`_
837
847
 
848
+
849
+
850
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limits
3
- Version: 5.0.0rc2
3
+ Version: 5.2.0
4
4
  Summary: Rate limiting utilities
5
5
  Home-page: https://limits.readthedocs.org
6
6
  Author: Ali-Akber Saifee
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
22
22
  Requires-Python: >=3.10
23
23
  License-File: LICENSE.txt
24
24
  Requires-Dist: deprecated>=1.2
25
- Requires-Dist: packaging<25,>=21
25
+ Requires-Dist: packaging<26,>=21
26
26
  Requires-Dist: typing_extensions
27
27
  Provides-Extra: redis
28
28
  Requires-Dist: redis!=4.5.2,!=4.5.3,<6.0.0,>3; extra == "redis"
@@ -29,7 +29,6 @@ colors = {
29
29
  "redis": "#D82C20",
30
30
  "mongodb": "#00ED64",
31
31
  "memcached": "",
32
-
33
32
  }
34
33
 
35
34
  html_theme = "furo"
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2025-04-15T12:47:18-0700",
11
+ "date": "2025-05-16T12:15:52-0700",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "ca0e9ca30c696af1102471218171c07ce8ee7644",
15
- "version": "5.0.0rc2"
14
+ "full-revisionid": "0c8d73757f54788d5fa213a678dfbdf3fdd7ccfb",
15
+ "version": "5.2.0"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -175,7 +175,11 @@ class SlidingWindowCounterSupport(ABC):
175
175
  """
176
176
 
177
177
  def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
178
- for method in {"acquire_sliding_window_entry", "get_sliding_window"}:
178
+ for method in {
179
+ "acquire_sliding_window_entry",
180
+ "get_sliding_window",
181
+ "clear_sliding_window",
182
+ }:
179
183
  setattr(
180
184
  cls,
181
185
  method,
@@ -218,3 +222,13 @@ class SlidingWindowCounterSupport(ABC):
218
222
  - current window TTL
219
223
  """
220
224
  raise NotImplementedError
225
+
226
+ @abstractmethod
227
+ async def clear_sliding_window(self, key: str, expiry: int) -> None:
228
+ """
229
+ Resets the rate limit key(s) for the sliding window
230
+
231
+ :param key: the key to clear rate limits for
232
+ :param expiry: the rate limit expiry, needed to compute the key in some implemenations
233
+ """
234
+ ...
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import time
4
5
  from math import floor
5
6
 
@@ -167,6 +168,11 @@ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingW
167
168
  previous_key, current_key, expiry, now
168
169
  )
169
170
 
171
+ async def clear_sliding_window(self, key: str, expiry: int) -> None:
172
+ now = time.time()
173
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
174
+ await asyncio.gather(self.clear(previous_key), self.clear(current_key))
175
+
170
176
  async def _get_sliding_window_info(
171
177
  self, previous_key: str, current_key: str, expiry: int, now: float
172
178
  ) -> tuple[int, float, int, float]:
@@ -241,6 +241,12 @@ class MemoryStorage(
241
241
  previous_key, current_key, expiry, now
242
242
  )
243
243
 
244
+ async def clear_sliding_window(self, key: str, expiry: int) -> None:
245
+ now = time.time()
246
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
247
+ await self.clear(current_key)
248
+ await self.clear(previous_key)
249
+
244
250
  async def _get_sliding_window_info(
245
251
  self,
246
252
  previous_key: str,
@@ -513,5 +513,8 @@ class MongoDBStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
513
513
  )
514
514
  return 0, 0.0, 0, 0.0
515
515
 
516
+ async def clear_sliding_window(self, key: str, expiry: int) -> None:
517
+ return await self.clear(key)
518
+
516
519
  def __del__(self) -> None:
517
520
  self.storage and self.storage.close()
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+
3
5
  from deprecated.sphinx import versionadded, versionchanged
4
6
  from packaging.version import Version
5
7
 
@@ -51,6 +53,8 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
51
53
  "valkey": Version("6.0"),
52
54
  }
53
55
  MODE: Literal["BASIC", "CLUSTER", "SENTINEL"] = "BASIC"
56
+ PREFIX = "LIMITS"
57
+
54
58
  bridge: RedisBridge
55
59
  storage_exceptions: tuple[Exception, ...]
56
60
  target_server: Literal["redis", "valkey"]
@@ -60,6 +64,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
60
64
  uri: str,
61
65
  wrap_exceptions: bool = False,
62
66
  implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
67
+ key_prefix: str = PREFIX,
63
68
  **options: float | str | bool,
64
69
  ) -> None:
65
70
  """
@@ -86,6 +91,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
86
91
  - ``redispy``: :class:`redis.asyncio.client.Redis`
87
92
  - ``valkey``: :class:`valkey.asyncio.client.Valkey`
88
93
 
94
+ :param key_prefix: the prefix for each key created in redis
89
95
  :param options: all remaining keyword arguments are passed
90
96
  directly to the constructor of :class:`coredis.Redis` or :class:`redis.asyncio.client.Redis`
91
97
  :raise ConfigurationError: when the redis library is not available
@@ -97,12 +103,18 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
97
103
  super().__init__(uri, wrap_exceptions=wrap_exceptions)
98
104
  self.options = options
99
105
  if self.target_server == "valkey" or implementation == "valkey":
100
- self.bridge = ValkeyBridge(uri, self.dependencies["valkey"].module)
106
+ self.bridge = ValkeyBridge(
107
+ uri, self.dependencies["valkey"].module, key_prefix
108
+ )
101
109
  else:
102
110
  if implementation == "redispy":
103
- self.bridge = RedispyBridge(uri, self.dependencies["redis"].module)
111
+ self.bridge = RedispyBridge(
112
+ uri, self.dependencies["redis"].module, key_prefix
113
+ )
104
114
  else:
105
- self.bridge = CoredisBridge(uri, self.dependencies["coredis"].module)
115
+ self.bridge = CoredisBridge(
116
+ uri, self.dependencies["coredis"].module, key_prefix
117
+ )
106
118
  self.configure_bridge()
107
119
  self.bridge.register_scripts()
108
120
 
@@ -209,6 +221,11 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
209
221
  current_key = self._current_window_key(key)
210
222
  return await self.bridge.get_sliding_window(previous_key, current_key, expiry)
211
223
 
224
+ async def clear_sliding_window(self, key: str, expiry: int) -> None:
225
+ previous_key = self._previous_window_key(key)
226
+ current_key = self._current_window_key(key)
227
+ await asyncio.gather(self.clear(previous_key), self.clear(current_key))
228
+
212
229
  async def get_expiry(self, key: str) -> float:
213
230
  """
214
231
  :param key: the key to get the expiry for
@@ -226,7 +243,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
226
243
  async def reset(self) -> int | None:
227
244
  """
228
245
  This function calls a Lua Script to delete keys prefixed with
229
- ``self.PREFIX`` in blocks of 5000.
246
+ :paramref:`RedisStorage.key_prefix` in blocks of 5000.
230
247
 
231
248
  .. warning:: This operation was designed to be fast, but was not tested
232
249
  on a large production based system. Be careful with its usage as it
@@ -268,6 +285,7 @@ class RedisClusterStorage(RedisStorage):
268
285
  uri: str,
269
286
  wrap_exceptions: bool = False,
270
287
  implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
288
+ key_prefix: str = RedisStorage.PREFIX,
271
289
  **options: float | str | bool,
272
290
  ) -> None:
273
291
  """
@@ -283,6 +301,7 @@ class RedisClusterStorage(RedisStorage):
283
301
  - ``coredis``: :class:`coredis.RedisCluster`
284
302
  - ``redispy``: :class:`redis.asyncio.cluster.RedisCluster`
285
303
  - ``valkey``: :class:`valkey.asyncio.cluster.ValkeyCluster`
304
+ :param key_prefix: the prefix for each key created in redis
286
305
  :param options: all remaining keyword arguments are passed
287
306
  directly to the constructor of :class:`coredis.RedisCluster` or
288
307
  :class:`redis.asyncio.RedisCluster`
@@ -293,6 +312,7 @@ class RedisClusterStorage(RedisStorage):
293
312
  uri,
294
313
  wrap_exceptions=wrap_exceptions,
295
314
  implementation=implementation,
315
+ key_prefix=key_prefix,
296
316
  **options,
297
317
  )
298
318
 
@@ -303,8 +323,8 @@ class RedisClusterStorage(RedisStorage):
303
323
  """
304
324
  Redis Clusters are sharded and deleting across shards
305
325
  can't be done atomically. Because of this, this reset loops over all
306
- keys that are prefixed with ``self.PREFIX`` and calls delete on them,
307
- one at a time.
326
+ keys that are prefixed with :paramref:`RedisClusterStorage.key_prefix`
327
+ and calls delete on them one at a time.
308
328
 
309
329
  .. warning:: This operation was not tested with extremely large data sets.
310
330
  On a large production based system, care should be taken with its
@@ -354,6 +374,7 @@ class RedisSentinelStorage(RedisStorage):
354
374
  uri: str,
355
375
  wrap_exceptions: bool = False,
356
376
  implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
377
+ key_prefix: str = RedisStorage.PREFIX,
357
378
  service_name: str | None = None,
358
379
  use_replicas: bool = True,
359
380
  sentinel_kwargs: dict[str, float | str | bool] | None = None,
@@ -372,6 +393,7 @@ class RedisSentinelStorage(RedisStorage):
372
393
  - ``coredis``: :class:`coredis.sentinel.Sentinel`
373
394
  - ``redispy``: :class:`redis.asyncio.sentinel.Sentinel`
374
395
  - ``valkey``: :class:`valkey.asyncio.sentinel.Sentinel`
396
+ :param key_prefix: the prefix for each key created in redis
375
397
  :param service_name: sentinel service name (if not provided in `uri`)
376
398
  :param use_replicas: Whether to use replicas for read only operations
377
399
  :param sentinel_kwargs: optional arguments to pass as
@@ -391,6 +413,7 @@ class RedisSentinelStorage(RedisStorage):
391
413
  uri,
392
414
  wrap_exceptions=wrap_exceptions,
393
415
  implementation=implementation,
416
+ key_prefix=key_prefix,
394
417
  **options,
395
418
  )
396
419
 
@@ -8,7 +8,6 @@ from limits.util import get_package_data
8
8
 
9
9
 
10
10
  class RedisBridge(ABC):
11
- PREFIX = "LIMITS"
12
11
  RES_DIR = "resources/redis/lua_scripts"
13
12
 
14
13
  SCRIPT_MOVING_WINDOW = get_package_data(f"{RES_DIR}/moving_window.lua")
@@ -26,18 +25,20 @@ class RedisBridge(ABC):
26
25
  self,
27
26
  uri: str,
28
27
  dependency: ModuleType,
28
+ key_prefix: str,
29
29
  ) -> None:
30
30
  self.uri = uri
31
31
  self.parsed_uri = urllib.parse.urlparse(self.uri)
32
32
  self.dependency = dependency
33
33
  self.parsed_auth = {}
34
+ self.key_prefix = key_prefix
34
35
  if self.parsed_uri.username:
35
36
  self.parsed_auth["username"] = self.parsed_uri.username
36
37
  if self.parsed_uri.password:
37
38
  self.parsed_auth["password"] = self.parsed_uri.password
38
39
 
39
40
  def prefixed_key(self, key: str) -> str:
40
- return f"{self.PREFIX}:{key}"
41
+ return f"{self.key_prefix}:{key}"
41
42
 
42
43
  @abstractmethod
43
44
  def register_scripts(self) -> None: ...
@@ -302,6 +302,11 @@ class SlidingWindowCounterRateLimiter(RateLimiter):
302
302
 
303
303
  return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
304
304
 
305
+ async def clear(self, item: RateLimitItem, *identifiers: str) -> None:
306
+ return await cast(
307
+ SlidingWindowCounterSupport, self.storage
308
+ ).clear_sliding_window(item.key_for(*identifiers), item.get_expiry())
309
+
305
310
 
306
311
  STRATEGIES = {
307
312
  "sliding-window-counter": SlidingWindowCounterRateLimiter,
@@ -167,7 +167,11 @@ class SlidingWindowCounterSupport(ABC):
167
167
  """
168
168
 
169
169
  def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
170
- for method in {"acquire_sliding_window_entry", "get_sliding_window"}:
170
+ for method in {
171
+ "acquire_sliding_window_entry",
172
+ "get_sliding_window",
173
+ "clear_sliding_window",
174
+ }:
171
175
  setattr(
172
176
  cls,
173
177
  method,
@@ -207,6 +211,16 @@ class SlidingWindowCounterSupport(ABC):
207
211
  """
208
212
  raise NotImplementedError
209
213
 
214
+ @abstractmethod
215
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
216
+ """
217
+ Resets the rate limit key(s) for the sliding window
218
+
219
+ :param key: the key to clear rate limits for
220
+ :param expiry: the rate limit expiry, needed to compute the key in some implemenations
221
+ """
222
+ ...
223
+
210
224
 
211
225
  class TimestampedSlidingWindow:
212
226
  """Helper class for storage that support the sliding window counter, with timestamp based keys."""
@@ -282,6 +282,12 @@ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingW
282
282
  previous_key, current_key = self.sliding_window_keys(key, expiry, now)
283
283
  return self._get_sliding_window_info(previous_key, current_key, expiry, now)
284
284
 
285
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
286
+ now = time.time()
287
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
288
+ self.clear(previous_key)
289
+ self.clear(current_key)
290
+
285
291
  def _get_sliding_window_info(
286
292
  self, previous_key: str, current_key: str, expiry: int, now: float
287
293
  ) -> tuple[int, float, int, float]:
@@ -237,6 +237,12 @@ class MemoryStorage(
237
237
  previous_key, current_key = self.sliding_window_keys(key, expiry, now)
238
238
  return self._get_sliding_window_info(previous_key, current_key, expiry, now)
239
239
 
240
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
241
+ now = time.time()
242
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
243
+ self.clear(previous_key)
244
+ self.clear(current_key)
245
+
240
246
  def check(self) -> bool:
241
247
  """
242
248
  check if storage is healthy
@@ -470,6 +470,9 @@ class MongoDBStorageBase(
470
470
  )
471
471
  return cast(bool, result["_acquired"])
472
472
 
473
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
474
+ return self.clear(key)
475
+
473
476
  def __del__(self) -> None:
474
477
  if self.storage:
475
478
  self.storage.close()
@@ -68,6 +68,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
68
68
  self,
69
69
  uri: str,
70
70
  connection_pool: redis.connection.ConnectionPool | None = None,
71
+ key_prefix: str = PREFIX,
71
72
  wrap_exceptions: bool = False,
72
73
  **options: float | str | bool,
73
74
  ) -> None:
@@ -82,6 +83,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
82
83
  :pypi:`valkey`.
83
84
  :param connection_pool: if provided, the redis client is initialized with
84
85
  the connection pool and any other params passed as :paramref:`options`
86
+ :param key_prefix: the prefix for each key created in redis
85
87
  :param wrap_exceptions: Whether to wrap storage exceptions in
86
88
  :exc:`limits.errors.StorageError` before raising it.
87
89
  :param options: all remaining keyword arguments are passed
@@ -89,6 +91,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
89
91
  :raise ConfigurationError: when the :pypi:`redis` library is not available
90
92
  """
91
93
  super().__init__(uri, wrap_exceptions=wrap_exceptions, **options)
94
+ self.key_prefix = key_prefix
92
95
  self.target_server = "valkey" if uri.startswith("valkey") else "redis"
93
96
  self.dependency = self.dependencies[self.target_server].module
94
97
 
@@ -165,7 +168,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
165
168
  return f"{self._current_window_key(key)}/-1"
166
169
 
167
170
  def prefixed_key(self, key: str) -> str:
168
- return f"{self.PREFIX}:{key}"
171
+ return f"{self.key_prefix}:{key}"
169
172
 
170
173
  def get_moving_window(self, key: str, limit: int, expiry: int) -> tuple[float, int]:
171
174
  """
@@ -197,6 +200,12 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
197
200
  )
198
201
  return 0, 0.0, 0, 0.0
199
202
 
203
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
204
+ previous_key = self._previous_window_key(key)
205
+ current_key = self._current_window_key(key)
206
+ self.clear(previous_key)
207
+ self.clear(current_key)
208
+
200
209
  def incr(
201
210
  self,
202
211
  key: str,
@@ -295,7 +304,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
295
304
  def reset(self) -> int | None:
296
305
  """
297
306
  This function calls a Lua Script to delete keys prefixed with
298
- ``self.PREFIX`` in blocks of 5000.
307
+ :paramref:`RedisStorage.key_prefix` in blocks of 5000.
299
308
 
300
309
  .. warning::
301
310
  This operation was designed to be fast, but was not tested
@@ -56,6 +56,7 @@ class RedisClusterStorage(RedisStorage):
56
56
  def __init__(
57
57
  self,
58
58
  uri: str,
59
+ key_prefix: str = RedisStorage.PREFIX,
59
60
  wrap_exceptions: bool = False,
60
61
  **options: float | str | bool,
61
62
  ) -> None:
@@ -65,6 +66,7 @@ class RedisClusterStorage(RedisStorage):
65
66
 
66
67
  If the uri scheme is ``valkey+cluster`` the implementation used will be from
67
68
  :pypi:`valkey`.
69
+ :param key_prefix: the prefix for each key created in redis
68
70
  :param wrap_exceptions: Whether to wrap storage exceptions in
69
71
  :exc:`limits.errors.StorageError` before raising it.
70
72
  :param options: all remaining keyword arguments are passed
@@ -86,6 +88,7 @@ class RedisClusterStorage(RedisStorage):
86
88
  host, port = loc.split(":")
87
89
  cluster_hosts.append((host, int(port)))
88
90
 
91
+ self.key_prefix = key_prefix
89
92
  self.storage = None
90
93
  self.target_server = "valkey" if uri.startswith("valkey") else "redis"
91
94
  merged_options = {**self.DEFAULT_OPTIONS, **parsed_auth, **options}
@@ -108,8 +111,8 @@ class RedisClusterStorage(RedisStorage):
108
111
  """
109
112
  Redis Clusters are sharded and deleting across shards
110
113
  can't be done atomically. Because of this, this reset loops over all
111
- keys that are prefixed with ``self.PREFIX`` and calls delete on them,
112
- one at a time.
114
+ keys that are prefixed with :paramref:`RedisClusterStorage.prefix` and
115
+ calls delete on them one at a time.
113
116
 
114
117
  .. warning::
115
118
  This operation was not tested with extremely large data sets.
@@ -45,6 +45,7 @@ class RedisSentinelStorage(RedisStorage):
45
45
  service_name: str | None = None,
46
46
  use_replicas: bool = True,
47
47
  sentinel_kwargs: dict[str, float | str | bool] | None = None,
48
+ key_prefix: str = RedisStorage.PREFIX,
48
49
  wrap_exceptions: bool = False,
49
50
  **options: float | str | bool,
50
51
  ) -> None:
@@ -59,6 +60,7 @@ class RedisSentinelStorage(RedisStorage):
59
60
  :param use_replicas: Whether to use replicas for read only operations
60
61
  :param sentinel_kwargs: kwargs to pass as
61
62
  :attr:`sentinel_kwargs` to :class:`redis.sentinel.Sentinel`
63
+ :param key_prefix: the prefix for each key created in redis
62
64
  :param wrap_exceptions: Whether to wrap storage exceptions in
63
65
  :exc:`limits.errors.StorageError` before raising it.
64
66
  :param options: all remaining keyword arguments are passed
@@ -87,6 +89,7 @@ class RedisSentinelStorage(RedisStorage):
87
89
  for loc in parsed.netloc[sep:].split(","):
88
90
  host, port = loc.split(":")
89
91
  sentinel_configuration.append((host, int(port)))
92
+ self.key_prefix = key_prefix
90
93
  self.service_name = (
91
94
  parsed.path.replace("/", "") if parsed.path else service_name
92
95
  )
@@ -284,6 +284,11 @@ class SlidingWindowCounterRateLimiter(RateLimiter):
284
284
 
285
285
  return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
286
286
 
287
+ def clear(self, item: RateLimitItem, *identifiers: str) -> None:
288
+ return cast(SlidingWindowCounterSupport, self.storage).clear_sliding_window(
289
+ item.key_for(*identifiers), item.get_expiry()
290
+ )
291
+
287
292
 
288
293
  KnownStrategy = (
289
294
  type[SlidingWindowCounterRateLimiter]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limits
3
- Version: 5.0.0rc2
3
+ Version: 5.2.0
4
4
  Summary: Rate limiting utilities
5
5
  Home-page: https://limits.readthedocs.org
6
6
  Author: Ali-Akber Saifee
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
22
22
  Requires-Python: >=3.10
23
23
  License-File: LICENSE.txt
24
24
  Requires-Dist: deprecated>=1.2
25
- Requires-Dist: packaging<25,>=21
25
+ Requires-Dist: packaging<26,>=21
26
26
  Requires-Dist: typing_extensions
27
27
  Provides-Extra: redis
28
28
  Requires-Dist: redis!=4.5.2,!=4.5.3,<6.0.0,>3; extra == "redis"
@@ -1,5 +1,5 @@
1
1
  deprecated>=1.2
2
- packaging<25,>=21
2
+ packaging<26,>=21
3
3
  typing_extensions
4
4
 
5
5
  [all]
@@ -1,3 +1,3 @@
1
1
  deprecated>=1.2
2
- packaging>=21,<25
2
+ packaging>=21,<26
3
3
  typing_extensions
@@ -136,6 +136,9 @@ class TestBaseStorage:
136
136
  ) -> tuple[int, float, int, float]:
137
137
  pass
138
138
 
139
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
140
+ pass
141
+
139
142
  storage = storage_from_string("mystorage+sliding://")
140
143
  assert isinstance(storage, MyStorage)
141
144
  SlidingWindowCounterRateLimiter(storage)
@@ -370,6 +373,9 @@ class TestStorageErrors:
370
373
  ) -> tuple[int, float, int, float]:
371
374
  raise self.MyError()
372
375
 
376
+ def clear_sliding_window(self, key: str, expiry: int) -> None:
377
+ raise self.MyError()
378
+
373
379
  def assert_exception(self, exc, wrap_exceptions):
374
380
  if wrap_exceptions:
375
381
  assert isinstance(exc, StorageError)
@@ -436,3 +442,9 @@ class TestStorageErrors:
436
442
  with pytest.raises(Exception) as exc:
437
443
  self.MyStorage(wrap_exceptions=wrap_exceptions).get_sliding_window("", 1)
438
444
  self.assert_exception(exc.value, wrap_exceptions)
445
+
446
+ def test_clear_sliding_window_exception(self, wrap_exceptions):
447
+ with pytest.raises(Exception) as exc:
448
+ self.MyStorage(wrap_exceptions=wrap_exceptions).clear_sliding_window("", 1)
449
+
450
+ self.assert_exception(exc.value, wrap_exceptions)
@@ -108,6 +108,9 @@ class TestSlidingWindow:
108
108
  start + 2, 1e-2
109
109
  )
110
110
 
111
+ limiter.clear(limit)
112
+ assert 10 == limiter.get_window_stats(limit).remaining
113
+
111
114
  @pytest.mark.flaky
112
115
  def test_sliding_window_counter_total_reset(self, uri, args, fixture):
113
116
  storage = storage_from_string(uri, **args)
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
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