limits 3.14.1__tar.gz → 4.0.1__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 (82) hide show
  1. {limits-3.14.1 → limits-4.0.1}/HISTORY.rst +21 -0
  2. {limits-3.14.1 → limits-4.0.1}/PKG-INFO +25 -2
  3. {limits-3.14.1 → limits-4.0.1}/README.rst +12 -0
  4. {limits-3.14.1 → limits-4.0.1}/doc/source/quickstart.rst +17 -4
  5. {limits-3.14.1 → limits-4.0.1}/doc/source/strategies.rst +1 -1
  6. {limits-3.14.1 → limits-4.0.1}/limits/_version.py +3 -3
  7. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/base.py +2 -2
  8. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/etcd.py +3 -3
  9. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/memcached.py +2 -2
  10. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/memory.py +5 -5
  11. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/mongodb.py +30 -30
  12. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/redis.py +8 -8
  13. {limits-3.14.1 → limits-4.0.1}/limits/resources/redis/lua_scripts/moving_window.lua +3 -1
  14. {limits-3.14.1 → limits-4.0.1}/limits/storage/__init__.py +3 -3
  15. {limits-3.14.1 → limits-4.0.1}/limits/storage/base.py +2 -2
  16. {limits-3.14.1 → limits-4.0.1}/limits/storage/etcd.py +3 -4
  17. {limits-3.14.1 → limits-4.0.1}/limits/storage/memcached.py +3 -4
  18. {limits-3.14.1 → limits-4.0.1}/limits/storage/memory.py +5 -5
  19. {limits-3.14.1 → limits-4.0.1}/limits/storage/mongodb.py +28 -24
  20. {limits-3.14.1 → limits-4.0.1}/limits/storage/redis.py +7 -6
  21. {limits-3.14.1 → limits-4.0.1}/limits/storage/redis_sentinel.py +1 -1
  22. {limits-3.14.1 → limits-4.0.1}/limits/util.py +1 -1
  23. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/PKG-INFO +25 -2
  24. {limits-3.14.1 → limits-4.0.1}/requirements/test.txt +1 -1
  25. {limits-3.14.1 → limits-4.0.1}/tests/test_strategy.py +21 -10
  26. {limits-3.14.1 → limits-4.0.1}/CLASSIFIERS +0 -0
  27. {limits-3.14.1 → limits-4.0.1}/CONTRIBUTIONS.rst +0 -0
  28. {limits-3.14.1 → limits-4.0.1}/LICENSE.txt +0 -0
  29. {limits-3.14.1 → limits-4.0.1}/MANIFEST.in +0 -0
  30. {limits-3.14.1 → limits-4.0.1}/doc/Makefile +0 -0
  31. {limits-3.14.1 → limits-4.0.1}/doc/source/_static/custom.css +0 -0
  32. {limits-3.14.1 → limits-4.0.1}/doc/source/api.rst +0 -0
  33. {limits-3.14.1 → limits-4.0.1}/doc/source/async.rst +0 -0
  34. {limits-3.14.1 → limits-4.0.1}/doc/source/changelog.rst +0 -0
  35. {limits-3.14.1 → limits-4.0.1}/doc/source/conf.py +0 -0
  36. {limits-3.14.1 → limits-4.0.1}/doc/source/custom-storage.rst +0 -0
  37. {limits-3.14.1 → limits-4.0.1}/doc/source/index.rst +0 -0
  38. {limits-3.14.1 → limits-4.0.1}/doc/source/installation.rst +0 -0
  39. {limits-3.14.1 → limits-4.0.1}/doc/source/storage.rst +0 -0
  40. {limits-3.14.1 → limits-4.0.1}/doc/source/theme_config.py +0 -0
  41. {limits-3.14.1 → limits-4.0.1}/limits/__init__.py +0 -0
  42. {limits-3.14.1 → limits-4.0.1}/limits/aio/__init__.py +0 -0
  43. {limits-3.14.1 → limits-4.0.1}/limits/aio/storage/__init__.py +0 -0
  44. {limits-3.14.1 → limits-4.0.1}/limits/aio/strategies.py +0 -0
  45. {limits-3.14.1 → limits-4.0.1}/limits/errors.py +0 -0
  46. {limits-3.14.1 → limits-4.0.1}/limits/limits.py +0 -0
  47. {limits-3.14.1 → limits-4.0.1}/limits/py.typed +0 -0
  48. {limits-3.14.1 → limits-4.0.1}/limits/resources/redis/lua_scripts/acquire_moving_window.lua +0 -0
  49. {limits-3.14.1 → limits-4.0.1}/limits/resources/redis/lua_scripts/clear_keys.lua +0 -0
  50. {limits-3.14.1 → limits-4.0.1}/limits/resources/redis/lua_scripts/incr_expire.lua +0 -0
  51. {limits-3.14.1 → limits-4.0.1}/limits/storage/redis_cluster.py +0 -0
  52. {limits-3.14.1 → limits-4.0.1}/limits/storage/registry.py +0 -0
  53. {limits-3.14.1 → limits-4.0.1}/limits/strategies.py +0 -0
  54. {limits-3.14.1 → limits-4.0.1}/limits/typing.py +0 -0
  55. {limits-3.14.1 → limits-4.0.1}/limits/version.py +0 -0
  56. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/SOURCES.txt +0 -0
  57. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/dependency_links.txt +0 -0
  58. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/not-zip-safe +0 -0
  59. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/requires.txt +0 -0
  60. {limits-3.14.1 → limits-4.0.1}/limits.egg-info/top_level.txt +0 -0
  61. {limits-3.14.1 → limits-4.0.1}/pyproject.toml +0 -0
  62. {limits-3.14.1 → limits-4.0.1}/requirements/ci.txt +0 -0
  63. {limits-3.14.1 → limits-4.0.1}/requirements/dev.txt +0 -0
  64. {limits-3.14.1 → limits-4.0.1}/requirements/docs.txt +0 -0
  65. {limits-3.14.1 → limits-4.0.1}/requirements/main.txt +0 -0
  66. {limits-3.14.1 → limits-4.0.1}/requirements/storage/async-etcd.txt +0 -0
  67. {limits-3.14.1 → limits-4.0.1}/requirements/storage/async-memcached.txt +0 -0
  68. {limits-3.14.1 → limits-4.0.1}/requirements/storage/async-mongodb.txt +0 -0
  69. {limits-3.14.1 → limits-4.0.1}/requirements/storage/async-redis.txt +0 -0
  70. {limits-3.14.1 → limits-4.0.1}/requirements/storage/etcd.txt +0 -0
  71. {limits-3.14.1 → limits-4.0.1}/requirements/storage/memcached.txt +0 -0
  72. {limits-3.14.1 → limits-4.0.1}/requirements/storage/mongodb.txt +0 -0
  73. {limits-3.14.1 → limits-4.0.1}/requirements/storage/redis.txt +0 -0
  74. {limits-3.14.1 → limits-4.0.1}/requirements/storage/rediscluster.txt +0 -0
  75. {limits-3.14.1 → limits-4.0.1}/setup.cfg +0 -0
  76. {limits-3.14.1 → limits-4.0.1}/setup.py +0 -0
  77. {limits-3.14.1 → limits-4.0.1}/tests/test_limit_granularities.py +0 -0
  78. {limits-3.14.1 → limits-4.0.1}/tests/test_limits.py +0 -0
  79. {limits-3.14.1 → limits-4.0.1}/tests/test_ratelimit_parser.py +0 -0
  80. {limits-3.14.1 → limits-4.0.1}/tests/test_storage.py +0 -0
  81. {limits-3.14.1 → limits-4.0.1}/tests/test_utils.py +0 -0
  82. {limits-3.14.1 → limits-4.0.1}/versioneer.py +0 -0
@@ -3,6 +3,25 @@
3
3
  Changelog
4
4
  =========
5
5
 
6
+ v4.0.1
7
+ ------
8
+ Release Date: 2025-01-16
9
+
10
+ Security
11
+
12
+ * Change pypi release to use trusted publishing
13
+
14
+ v4.0.0
15
+ ------
16
+ Release Date: 2025-01-05
17
+
18
+ * Breaking change
19
+
20
+ * Change definition of ``reset_time`` in ``get_window_stats``
21
+ to use a precise floating point value instead of truncating
22
+ to the previous second.
23
+
24
+
6
25
  v3.14.1
7
26
  -------
8
27
  Release Date: 2024-11-30
@@ -743,6 +762,8 @@ Release Date: 2015-01-08
743
762
 
744
763
 
745
764
 
765
+
766
+
746
767
 
747
768
 
748
769
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: limits
3
- Version: 3.14.1
3
+ Version: 4.0.1
4
4
  Summary: Rate limiting utilities
5
5
  Home-page: https://limits.readthedocs.org
6
6
  Author: Ali-Akber Saifee
@@ -55,6 +55,17 @@ Requires-Dist: emcache>=0.6.1; python_version < "3.11" and extra == "all"
55
55
  Requires-Dist: emcache>=1; (python_version >= "3.11" and python_version < "3.13.0") and extra == "all"
56
56
  Requires-Dist: motor<4,>=3; extra == "all"
57
57
  Requires-Dist: aetcd; extra == "all"
58
+ Dynamic: author
59
+ Dynamic: author-email
60
+ Dynamic: classifier
61
+ Dynamic: description
62
+ Dynamic: home-page
63
+ Dynamic: license
64
+ Dynamic: project-url
65
+ Dynamic: provides-extra
66
+ Dynamic: requires-dist
67
+ Dynamic: requires-python
68
+ Dynamic: summary
58
69
 
59
70
  .. |ci| image:: https://github.com/alisaifee/limits/workflows/CI/badge.svg?branch=master
60
71
  :target: https://github.com/alisaifee/limits/actions?query=branch%3Amaster+workflow%3ACI
@@ -172,6 +183,18 @@ Check specific limits without hitting them
172
183
  time.sleep(0.01)
173
184
  assert True == moving_window.hit(one_per_second, "test_namespace", "foo")
174
185
 
186
+ Query available capacity and reset time for a limit
187
+
188
+ .. code-block:: python
189
+
190
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
191
+ window = moving_window.get_window_stats(one_per_minute, "test_namespace", "foo")
192
+ assert window.remaining == 0
193
+ assert False == moving_window.hit(one_per_minute, "test_namespace", "foo")
194
+ time.sleep(window.reset_time - time.time())
195
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
196
+
197
+
175
198
  Links
176
199
  =====
177
200
 
@@ -114,6 +114,18 @@ Check specific limits without hitting them
114
114
  time.sleep(0.01)
115
115
  assert True == moving_window.hit(one_per_second, "test_namespace", "foo")
116
116
 
117
+ Query available capacity and reset time for a limit
118
+
119
+ .. code-block:: python
120
+
121
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
122
+ window = moving_window.get_window_stats(one_per_minute, "test_namespace", "foo")
123
+ assert window.remaining == 0
124
+ assert False == moving_window.hit(one_per_minute, "test_namespace", "foo")
125
+ time.sleep(window.reset_time - time.time())
126
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
127
+
128
+
117
129
  Links
118
130
  =====
119
131
 
@@ -13,14 +13,14 @@ Initialize the storage backend
13
13
  .. code::
14
14
 
15
15
  from limits import storage
16
- memory_storage = storage.MemoryStorage()
16
+ limits_storage = storage.MemoryStorage()
17
17
 
18
18
  .. tab:: Memcached
19
19
 
20
20
  .. code::
21
21
 
22
22
  from limits import storage
23
- memory_storage = storage.MemcachedStorage(
23
+ limits_storage = storage.MemcachedStorage(
24
24
  "memcached://localhost:11211"
25
25
  )
26
26
 
@@ -29,7 +29,7 @@ Initialize the storage backend
29
29
  .. code::
30
30
 
31
31
  from limits import storage
32
- memory_storage = storage.RedisStorage("redis://localhost:6379/1")
32
+ limits_storage = storage.RedisStorage("redis://localhost:6379/1")
33
33
 
34
34
  Initialize a rate limiter with the :ref:`Moving Window<strategies:moving window>` Strategy
35
35
  ------------------------------------------------------------------------------------------
@@ -37,7 +37,7 @@ Initialize a rate limiter with the :ref:`Moving Window<strategies:moving window>
37
37
  .. code::
38
38
 
39
39
  from limits import strategies
40
- moving_window = strategies.MovingWindowRateLimiter(memory_storage)
40
+ moving_window = strategies.MovingWindowRateLimiter(limits_storage)
41
41
 
42
42
 
43
43
  Describe the rate limit
@@ -87,6 +87,19 @@ Check without consuming
87
87
  time.sleep(0.01)
88
88
  assert True == moving_window.hit(one_per_second, "test_namespace", "foo")
89
89
 
90
+ Query available capacity and reset time
91
+ -----------------------------------------
92
+
93
+ .. code::
94
+
95
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
96
+ window = moving_window.get_window_stats(one_per_minute, "test_namespace", "foo")
97
+ assert window.remaining == 0
98
+ assert False == moving_window.hit(one_per_minute, "test_namespace", "foo")
99
+ time.sleep(window.reset_time - time.time())
100
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
101
+
102
+
90
103
  Clear a limit
91
104
  =============
92
105
 
@@ -35,7 +35,7 @@ Moving Window
35
35
  =============
36
36
 
37
37
  .. warning:: The moving window strategy is not implemented for the ``memcached``
38
- storage backend.
38
+ and ``etcd`` storage backends.
39
39
 
40
40
  This strategy is the most effective for preventing bursts from by-passing the
41
41
  rate limit as the window for each limit is not fixed at the start and end of each time unit
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2024-11-30T11:04:11-0800",
11
+ "date": "2025-01-16T11:38:03-0800",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "0671723f54aed5692d4c9d9b47cf0326d5263de5",
15
- "version": "3.14.1"
14
+ "full-revisionid": "074be17ab3008f50de700e996d243ba85580b058",
15
+ "version": "4.0.1"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -103,7 +103,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
103
103
  raise NotImplementedError
104
104
 
105
105
  @abstractmethod
106
- async def get_expiry(self, key: str) -> int:
106
+ async def get_expiry(self, key: str) -> float:
107
107
  """
108
108
  :param key: the key to get the expiry for
109
109
  """
@@ -169,7 +169,7 @@ class MovingWindowSupport(ABC):
169
169
  @abstractmethod
170
170
  async def get_moving_window(
171
171
  self, key: str, limit: int, expiry: int
172
- ) -> Tuple[int, int]:
172
+ ) -> Tuple[float, int]:
173
173
  """
174
174
  returns the starting point and the number of entries in the moving
175
175
  window
@@ -116,12 +116,12 @@ class EtcdStorage(Storage):
116
116
  return int(amount)
117
117
  return 0
118
118
 
119
- async def get_expiry(self, key: str) -> int:
119
+ async def get_expiry(self, key: str) -> float:
120
120
  cur = await self.storage.get(self.prefixed_key(key))
121
121
  if cur:
122
122
  window_end = float(cur.value.split(b":")[1])
123
- return int(window_end)
124
- return int(time.time())
123
+ return window_end
124
+ return time.time()
125
125
 
126
126
  async def check(self) -> bool:
127
127
  try:
@@ -126,14 +126,14 @@ class MemcachedStorage(Storage):
126
126
 
127
127
  return amount
128
128
 
129
- async def get_expiry(self, key: str) -> int:
129
+ async def get_expiry(self, key: str) -> float:
130
130
  """
131
131
  :param key: the key to get the expiry for
132
132
  """
133
133
  storage = await self.get_storage()
134
134
  item = await storage.get(f"{key}/expires".encode())
135
135
 
136
- return int(item and float(item.value) or time.time())
136
+ return item and float(item.value) or time.time()
137
137
 
138
138
  async def check(self) -> bool:
139
139
  """
@@ -128,12 +128,12 @@ class MemoryStorage(Storage, MovingWindowSupport):
128
128
 
129
129
  return True
130
130
 
131
- async def get_expiry(self, key: str) -> int:
131
+ async def get_expiry(self, key: str) -> float:
132
132
  """
133
133
  :param key: the key to get the expiry for
134
134
  """
135
135
 
136
- return int(self.expirations.get(key, time.time()))
136
+ return self.expirations.get(key, time.time())
137
137
 
138
138
  async def get_num_acquired(self, key: str, expiry: int) -> int:
139
139
  """
@@ -153,7 +153,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
153
153
  # FIXME: arg limit is not used
154
154
  async def get_moving_window(
155
155
  self, key: str, limit: int, expiry: int
156
- ) -> Tuple[int, int]:
156
+ ) -> Tuple[float, int]:
157
157
  """
158
158
  returns the starting point and the number of entries in the moving
159
159
  window
@@ -167,9 +167,9 @@ class MemoryStorage(Storage, MovingWindowSupport):
167
167
 
168
168
  for item in self.events.get(key, [])[::-1]:
169
169
  if item.atime >= timestamp - expiry:
170
- return int(item.atime), acquired
170
+ return item.atime, acquired
171
171
 
172
- return int(timestamp), acquired
172
+ return timestamp, acquired
173
173
 
174
174
  async def check(self) -> bool:
175
175
  """
@@ -1,15 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import calendar
5
4
  import datetime
6
5
  import time
7
- from typing import Any, cast
6
+ from typing import cast
8
7
 
9
8
  from deprecated.sphinx import versionadded, versionchanged
10
9
 
11
10
  from limits.aio.storage.base import MovingWindowSupport, Storage
12
- from limits.typing import Dict, Optional, ParamSpec, Tuple, Type, TypeVar, Union
11
+ from limits.typing import Dict, List, Optional, ParamSpec, Tuple, Type, TypeVar, Union
13
12
  from limits.util import get_dependency
14
13
 
15
14
  P = ParamSpec("P")
@@ -135,21 +134,19 @@ class MongoDBStorage(Storage, MovingWindowSupport):
135
134
  ),
136
135
  )
137
136
 
138
- async def get_expiry(self, key: str) -> int:
137
+ async def get_expiry(self, key: str) -> float:
139
138
  """
140
139
  :param key: the key to get the expiry for
141
140
  """
142
141
  counter = await self.database[self.__collection_mapping["counters"]].find_one(
143
142
  {"_id": key}
144
143
  )
145
- expiry = (
146
- counter["expireAt"]
147
- if counter
148
- else datetime.datetime.now(datetime.timezone.utc)
144
+ return (
145
+ (counter["expireAt"] if counter else datetime.datetime.now())
146
+ .replace(tzinfo=datetime.timezone.utc)
147
+ .timestamp()
149
148
  )
150
149
 
151
- return calendar.timegm(expiry.timetuple())
152
-
153
150
  async def get(self, key: str) -> int:
154
151
  """
155
152
  :param key: the key to get the counter value for
@@ -227,7 +224,7 @@ class MongoDBStorage(Storage, MovingWindowSupport):
227
224
 
228
225
  async def get_moving_window(
229
226
  self, key: str, limit: int, expiry: int
230
- ) -> Tuple[int, int]:
227
+ ) -> Tuple[float, int]:
231
228
  """
232
229
  returns the starting point and the number of entries in the moving
233
230
  window
@@ -237,7 +234,7 @@ class MongoDBStorage(Storage, MovingWindowSupport):
237
234
  :return: (start of window, number of acquired entries)
238
235
  """
239
236
  timestamp = time.time()
240
- result = (
237
+ if result := (
241
238
  await self.database[self.__collection_mapping["windows"]]
242
239
  .aggregate(
243
240
  [
@@ -264,12 +261,9 @@ class MongoDBStorage(Storage, MovingWindowSupport):
264
261
  ]
265
262
  )
266
263
  .to_list(length=1)
267
- )
268
-
269
- if result:
270
- return (int(result[0]["min"]), result[0]["count"])
271
-
272
- return (int(timestamp), 0)
264
+ ):
265
+ return result[0]["min"], result[0]["count"]
266
+ return timestamp, 0
273
267
 
274
268
  async def acquire_entry(
275
269
  self, key: str, limit: int, expiry: int, amount: int = 1
@@ -287,23 +281,29 @@ class MongoDBStorage(Storage, MovingWindowSupport):
287
281
 
288
282
  timestamp = time.time()
289
283
  try:
290
- updates: Dict[str, Any] = { # type: ignore
291
- "$push": {"entries": {"$each": [], "$position": 0, "$slice": limit}}
284
+ updates: Dict[
285
+ str,
286
+ Dict[str, Union[datetime.datetime, Dict[str, Union[List[float], int]]]],
287
+ ] = {
288
+ "$push": {
289
+ "entries": {
290
+ "$each": [timestamp] * amount,
291
+ "$position": 0,
292
+ "$slice": limit,
293
+ }
294
+ },
295
+ "$set": {
296
+ "expireAt": (
297
+ datetime.datetime.now(datetime.timezone.utc)
298
+ + datetime.timedelta(seconds=expiry)
299
+ )
300
+ },
292
301
  }
293
302
 
294
- updates["$set"] = {
295
- "expireAt": (
296
- datetime.datetime.now(datetime.timezone.utc)
297
- + datetime.timedelta(seconds=expiry)
298
- )
299
- }
300
- updates["$push"]["entries"]["$each"] = [timestamp] * amount
301
303
  await self.database[self.__collection_mapping["windows"]].update_one(
302
304
  {
303
305
  "_id": key,
304
- "entries.%d" % (limit - amount): {
305
- "$not": {"$gte": timestamp - expiry}
306
- },
306
+ f"entries.{limit - amount}": {"$not": {"$gte": timestamp - expiry}},
307
307
  },
308
308
  updates,
309
309
  upsert=True,
@@ -78,7 +78,7 @@ class RedisInteractor:
78
78
 
79
79
  async def get_moving_window(
80
80
  self, key: str, limit: int, expiry: int
81
- ) -> Tuple[int, int]:
81
+ ) -> Tuple[float, int]:
82
82
  """
83
83
  returns the starting point and the number of entries in the moving
84
84
  window
@@ -88,12 +88,12 @@ class RedisInteractor:
88
88
  :return: (start of window, number of acquired entries)
89
89
  """
90
90
  key = self.prefixed_key(key)
91
- timestamp = int(time.time())
91
+ timestamp = time.time()
92
92
  window = await self.lua_moving_window.execute(
93
- [key], [int(timestamp - expiry), limit]
93
+ [key], [timestamp - expiry, limit]
94
94
  )
95
95
  if window:
96
- return tuple(window) # type: ignore
96
+ return float(window[0]), window[1] # type: ignore
97
97
  return timestamp, 0
98
98
 
99
99
  async def _acquire_entry(
@@ -118,14 +118,14 @@ class RedisInteractor:
118
118
 
119
119
  return bool(acquired)
120
120
 
121
- async def _get_expiry(self, key: str, connection: AsyncRedisClient) -> int:
121
+ async def _get_expiry(self, key: str, connection: AsyncRedisClient) -> float:
122
122
  """
123
123
  :param key: the key to get the expiry for
124
124
  :param connection: Redis connection
125
125
  """
126
126
 
127
127
  key = self.prefixed_key(key)
128
- return int(max(await connection.ttl(key), 0) + time.time())
128
+ return max(await connection.ttl(key), 0) + time.time()
129
129
 
130
130
  async def _check(self, connection: AsyncRedisClient) -> bool:
131
131
  """
@@ -261,7 +261,7 @@ class RedisStorage(RedisInteractor, Storage, MovingWindowSupport):
261
261
 
262
262
  return await super()._acquire_entry(key, limit, expiry, self.storage, amount)
263
263
 
264
- async def get_expiry(self, key: str) -> int:
264
+ async def get_expiry(self, key: str) -> float:
265
265
  """
266
266
  :param key: the key to get the expiry for
267
267
  """
@@ -450,7 +450,7 @@ class RedisSentinelStorage(RedisStorage):
450
450
  key, self.storage_replica if self.use_replicas else self.storage
451
451
  )
452
452
 
453
- async def get_expiry(self, key: str) -> int:
453
+ async def get_expiry(self, key: str) -> float:
454
454
  """
455
455
  :param key: the key to get the expiry for
456
456
  """
@@ -16,4 +16,6 @@ for idx=1,#items do
16
16
  end
17
17
  end
18
18
 
19
- return {oldest, a}
19
+ if oldest then
20
+ return {tostring(oldest), a}
21
+ end
@@ -32,9 +32,9 @@ def storage_from_string(
32
32
 
33
33
  from limits.storage import storage_from_string
34
34
 
35
- memory = from_string("memory://")
36
- memcached = from_string("memcached://localhost:11211")
37
- redis = from_string("redis://localhost:6379")
35
+ memory = storage_from_string("memory://")
36
+ memcached = storage_from_string("memcached://localhost:11211")
37
+ redis = storage_from_string("redis://localhost:6379")
38
38
 
39
39
  The same function can be used to construct the :ref:`storage:async storage`
40
40
  variants, for example::
@@ -99,7 +99,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
99
99
  raise NotImplementedError
100
100
 
101
101
  @abstractmethod
102
- def get_expiry(self, key: str) -> int:
102
+ def get_expiry(self, key: str) -> float:
103
103
  """
104
104
  :param key: the key to get the expiry for
105
105
  """
@@ -161,7 +161,7 @@ class MovingWindowSupport(ABC):
161
161
  raise NotImplementedError
162
162
 
163
163
  @abstractmethod
164
- def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[int, int]:
164
+ def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[float, int]:
165
165
  """
166
166
  returns the starting point and the number of entries in the moving
167
167
  window
@@ -110,12 +110,11 @@ class EtcdStorage(Storage):
110
110
  return int(amount)
111
111
  return 0
112
112
 
113
- def get_expiry(self, key: str) -> int:
113
+ def get_expiry(self, key: str) -> float:
114
114
  value, _ = self.storage.get(self.prefixed_key(key))
115
115
  if value:
116
- window_end = float(value.split(b":")[1])
117
- return int(window_end)
118
- return int(time.time())
116
+ return float(value.split(b":")[1])
117
+ return time.time()
119
118
 
120
119
  def check(self) -> bool:
121
120
  try:
@@ -77,8 +77,7 @@ class MemcachedStorage(Storage):
77
77
 
78
78
  if not get_dependency(self.library):
79
79
  raise ConfigurationError(
80
- "memcached prerequisite not available."
81
- " please install %s" % self.library
80
+ "memcached prerequisite not available. please install %s" % self.library
82
81
  ) # pragma: no cover
83
82
  self.local_storage = threading.local()
84
83
  self.local_storage.storage = None
@@ -192,12 +191,12 @@ class MemcachedStorage(Storage):
192
191
 
193
192
  return amount
194
193
 
195
- def get_expiry(self, key: str) -> int:
194
+ def get_expiry(self, key: str) -> float:
196
195
  """
197
196
  :param key: the key to get the expiry for
198
197
  """
199
198
 
200
- return int(float(self.storage.get(key + "/expires") or time.time()))
199
+ return float(self.storage.get(key + "/expires") or time.time())
201
200
 
202
201
  def check(self) -> bool:
203
202
  """
@@ -121,12 +121,12 @@ class MemoryStorage(Storage, MovingWindowSupport):
121
121
  self.events[key][:0] = [LockableEntry(expiry) for _ in range(amount)]
122
122
  return True
123
123
 
124
- def get_expiry(self, key: str) -> int:
124
+ def get_expiry(self, key: str) -> float:
125
125
  """
126
126
  :param key: the key to get the expiry for
127
127
  """
128
128
 
129
- return int(self.expirations.get(key, time.time()))
129
+ return self.expirations.get(key, time.time())
130
130
 
131
131
  def get_num_acquired(self, key: str, expiry: int) -> int:
132
132
  """
@@ -143,7 +143,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
143
143
  else 0
144
144
  )
145
145
 
146
- def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[int, int]:
146
+ def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[float, int]:
147
147
  """
148
148
  returns the starting point and the number of entries in the moving
149
149
  window
@@ -157,9 +157,9 @@ class MemoryStorage(Storage, MovingWindowSupport):
157
157
 
158
158
  for item in self.events.get(key, [])[::-1]:
159
159
  if item.atime >= timestamp - expiry:
160
- return int(item.atime), acquired
160
+ return item.atime, acquired
161
161
 
162
- return int(timestamp), acquired
162
+ return timestamp, acquired
163
163
 
164
164
  def check(self) -> bool:
165
165
  """
@@ -1,15 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- import calendar
4
3
  import datetime
5
4
  import time
6
5
  from abc import ABC, abstractmethod
7
- from typing import Any, cast
6
+ from typing import cast
8
7
 
9
8
  from deprecated.sphinx import versionadded, versionchanged
10
9
 
11
10
  from limits.typing import (
12
11
  Dict,
12
+ List,
13
13
  MongoClient,
14
14
  MongoCollection,
15
15
  MongoDatabase,
@@ -122,19 +122,17 @@ class MongoDBStorageBase(Storage, MovingWindowSupport, ABC):
122
122
  self.counters.find_one_and_delete({"_id": key})
123
123
  self.windows.find_one_and_delete({"_id": key})
124
124
 
125
- def get_expiry(self, key: str) -> int:
125
+ def get_expiry(self, key: str) -> float:
126
126
  """
127
127
  :param key: the key to get the expiry for
128
128
  """
129
129
  counter = self.counters.find_one({"_id": key})
130
- expiry = (
131
- counter["expireAt"]
132
- if counter
133
- else datetime.datetime.now(datetime.timezone.utc)
130
+ return (
131
+ (counter["expireAt"] if counter else datetime.datetime.now())
132
+ .replace(tzinfo=datetime.timezone.utc)
133
+ .timestamp()
134
134
  )
135
135
 
136
- return calendar.timegm(expiry.timetuple())
137
-
138
136
  def get(self, key: str) -> int:
139
137
  """
140
138
  :param key: the key to get the counter value for
@@ -205,7 +203,7 @@ class MongoDBStorageBase(Storage, MovingWindowSupport, ABC):
205
203
  except: # noqa: E722
206
204
  return False
207
205
 
208
- def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[int, int]:
206
+ def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[float, int]:
209
207
  """
210
208
  returns the starting point and the number of entries in the moving
211
209
  window
@@ -243,9 +241,9 @@ class MongoDBStorageBase(Storage, MovingWindowSupport, ABC):
243
241
  )
244
242
 
245
243
  if result:
246
- return int(result[0]["min"]), result[0]["count"]
244
+ return result[0]["min"], result[0]["count"]
247
245
 
248
- return int(timestamp), 0
246
+ return timestamp, 0
249
247
 
250
248
  def acquire_entry(self, key: str, limit: int, expiry: int, amount: int = 1) -> bool:
251
249
  """
@@ -259,23 +257,29 @@ class MongoDBStorageBase(Storage, MovingWindowSupport, ABC):
259
257
 
260
258
  timestamp = time.time()
261
259
  try:
262
- updates: Dict[str, Any] = { # type: ignore
263
- "$push": {"entries": {"$each": [], "$position": 0, "$slice": limit}}
260
+ updates: Dict[
261
+ str,
262
+ Dict[str, Union[datetime.datetime, Dict[str, Union[List[float], int]]]],
263
+ ] = {
264
+ "$push": {
265
+ "entries": {
266
+ "$each": [timestamp] * amount,
267
+ "$position": 0,
268
+ "$slice": limit,
269
+ }
270
+ },
271
+ "$set": {
272
+ "expireAt": (
273
+ datetime.datetime.now(datetime.timezone.utc)
274
+ + datetime.timedelta(seconds=expiry)
275
+ )
276
+ },
264
277
  }
265
278
 
266
- updates["$set"] = {
267
- "expireAt": (
268
- datetime.datetime.now(datetime.timezone.utc)
269
- + datetime.timedelta(seconds=expiry)
270
- )
271
- }
272
- updates["$push"]["entries"]["$each"] = [timestamp] * amount
273
279
  self.windows.update_one(
274
280
  {
275
281
  "_id": key,
276
- "entries.%d" % (limit - amount): {
277
- "$not": {"$gte": timestamp - expiry}
278
- },
282
+ f"entries.{limit - amount}": {"$not": {"$gte": timestamp - expiry}},
279
283
  },
280
284
  updates,
281
285
  upsert=True,
@@ -32,7 +32,7 @@ class RedisInteractor:
32
32
  def prefixed_key(self, key: str) -> str:
33
33
  return f"{self.PREFIX}:{key}"
34
34
 
35
- def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[int, int]:
35
+ def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[float, int]:
36
36
  """
37
37
  returns the starting point and the number of entries in the moving
38
38
  window
@@ -43,9 +43,10 @@ class RedisInteractor:
43
43
  """
44
44
  key = self.prefixed_key(key)
45
45
  timestamp = time.time()
46
- window = self.lua_moving_window([key], [int(timestamp - expiry), limit])
46
+ if window := self.lua_moving_window([key], [timestamp - expiry, limit]):
47
+ return float(window[0]), window[1]
47
48
 
48
- return window or (int(timestamp), 0)
49
+ return timestamp, 0
49
50
 
50
51
  def _incr(
51
52
  self,
@@ -109,14 +110,14 @@ class RedisInteractor:
109
110
 
110
111
  return bool(acquired)
111
112
 
112
- def _get_expiry(self, key: str, connection: RedisClient) -> int:
113
+ def _get_expiry(self, key: str, connection: RedisClient) -> float:
113
114
  """
114
115
  :param key: the key to get the expiry for
115
116
  :param connection: Redis connection
116
117
  """
117
118
 
118
119
  key = self.prefixed_key(key)
119
- return int(max(connection.ttl(key), 0) + time.time())
120
+ return max(connection.ttl(key), 0) + time.time()
120
121
 
121
122
  def _check(self, connection: RedisClient) -> bool:
122
123
  """
@@ -232,7 +233,7 @@ class RedisStorage(RedisInteractor, Storage, MovingWindowSupport):
232
233
 
233
234
  return super()._acquire_entry(key, limit, expiry, self.storage, amount)
234
235
 
235
- def get_expiry(self, key: str) -> int:
236
+ def get_expiry(self, key: str) -> float:
236
237
  """
237
238
  :param key: the key to get the expiry for
238
239
  """
@@ -101,7 +101,7 @@ class RedisSentinelStorage(RedisStorage):
101
101
  key, self.storage_slave if self.use_replicas else self.storage
102
102
  )
103
103
 
104
- def get_expiry(self, key: str) -> int:
104
+ def get_expiry(self, key: str) -> float:
105
105
  """
106
106
  :param key: the key to get the expiry for
107
107
  """
@@ -38,7 +38,7 @@ class WindowStats(NamedTuple):
38
38
  """
39
39
 
40
40
  #: Time as seconds since the Epoch when this window will be reset
41
- reset_time: int
41
+ reset_time: float
42
42
  #: Quantity remaining in this window
43
43
  remaining: int
44
44
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: limits
3
- Version: 3.14.1
3
+ Version: 4.0.1
4
4
  Summary: Rate limiting utilities
5
5
  Home-page: https://limits.readthedocs.org
6
6
  Author: Ali-Akber Saifee
@@ -55,6 +55,17 @@ Requires-Dist: emcache>=0.6.1; python_version < "3.11" and extra == "all"
55
55
  Requires-Dist: emcache>=1; (python_version >= "3.11" and python_version < "3.13.0") and extra == "all"
56
56
  Requires-Dist: motor<4,>=3; extra == "all"
57
57
  Requires-Dist: aetcd; extra == "all"
58
+ Dynamic: author
59
+ Dynamic: author-email
60
+ Dynamic: classifier
61
+ Dynamic: description
62
+ Dynamic: home-page
63
+ Dynamic: license
64
+ Dynamic: project-url
65
+ Dynamic: provides-extra
66
+ Dynamic: requires-dist
67
+ Dynamic: requires-python
68
+ Dynamic: summary
58
69
 
59
70
  .. |ci| image:: https://github.com/alisaifee/limits/workflows/CI/badge.svg?branch=master
60
71
  :target: https://github.com/alisaifee/limits/actions?query=branch%3Amaster+workflow%3ACI
@@ -172,6 +183,18 @@ Check specific limits without hitting them
172
183
  time.sleep(0.01)
173
184
  assert True == moving_window.hit(one_per_second, "test_namespace", "foo")
174
185
 
186
+ Query available capacity and reset time for a limit
187
+
188
+ .. code-block:: python
189
+
190
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
191
+ window = moving_window.get_window_stats(one_per_minute, "test_namespace", "foo")
192
+ assert window.remaining == 0
193
+ assert False == moving_window.hit(one_per_minute, "test_namespace", "foo")
194
+ time.sleep(window.reset_time - time.time())
195
+ assert True == moving_window.hit(one_per_minute, "test_namespace", "foo")
196
+
197
+
175
198
  Links
176
199
  =====
177
200
 
@@ -18,7 +18,7 @@ hiro>0.1.6
18
18
  flaky
19
19
  lovely-pytest-docker
20
20
  pytest<9
21
- pytest-asyncio==0.21.2
21
+ pytest-asyncio<0.26
22
22
  pytest-benchmark[histogram]
23
23
  pytest-cov
24
24
  pytest-lazy-fixtures
@@ -1,4 +1,3 @@
1
- import math
2
1
  import time
3
2
 
4
3
  import pytest
@@ -28,7 +27,9 @@ class TestWindow:
28
27
  assert all([limiter.hit(limit) for _ in range(0, 10)])
29
28
  assert not limiter.hit(limit)
30
29
  assert limiter.get_window_stats(limit).remaining == 0
31
- assert limiter.get_window_stats(limit).reset_time == math.floor(start + 2)
30
+ assert limiter.get_window_stats(limit).reset_time == pytest.approx(
31
+ start + 2, 1e-2
32
+ )
32
33
 
33
34
  @all_storage
34
35
  @fixed_start
@@ -37,7 +38,9 @@ class TestWindow:
37
38
  limiter = FixedWindowRateLimiter(storage)
38
39
  limit = RateLimitItemPerSecond(10, 2)
39
40
  assert limiter.get_window_stats(limit).remaining == 10
40
- assert limiter.get_window_stats(limit).reset_time == int(time.time())
41
+ assert limiter.get_window_stats(limit).reset_time == pytest.approx(
42
+ time.time(), 1e-2
43
+ )
41
44
 
42
45
  @all_storage
43
46
  @fixed_start
@@ -61,12 +64,16 @@ class TestWindow:
61
64
  assert all([limiter.hit(limit) for _ in range(0, 10)])
62
65
  assert not limiter.hit(limit)
63
66
  assert limiter.get_window_stats(limit).remaining == 0
64
- assert limiter.get_window_stats(limit).reset_time == start + 2
67
+ assert limiter.get_window_stats(limit).reset_time == pytest.approx(
68
+ start + 2, 1e-2
69
+ )
65
70
  with window(3) as (start, end):
66
71
  assert not limiter.hit(limit)
67
72
  assert limiter.hit(limit)
68
73
  assert limiter.get_window_stats(limit).remaining == 9
69
- assert limiter.get_window_stats(limit).reset_time == end + 2
74
+ assert limiter.get_window_stats(limit).reset_time == pytest.approx(
75
+ end + 2, 1e-2
76
+ )
70
77
 
71
78
  @all_storage
72
79
  @fixed_start
@@ -78,7 +85,9 @@ class TestWindow:
78
85
  with window(0) as (start, end):
79
86
  assert limiter.hit(limit, "k2", cost=5)
80
87
  assert limiter.get_window_stats(limit, "k2").remaining == 5
81
- assert limiter.get_window_stats(limit, "k2").reset_time == end + 2
88
+ assert limiter.get_window_stats(limit, "k2").reset_time == pytest.approx(
89
+ end + 2, 1e-2
90
+ )
82
91
  assert not limiter.hit(limit, "k2", cost=6)
83
92
 
84
93
  @moving_window_storage
@@ -87,7 +96,9 @@ class TestWindow:
87
96
  limiter = MovingWindowRateLimiter(storage)
88
97
  limit = RateLimitItemPerSecond(10, 2)
89
98
  assert limiter.get_window_stats(limit).remaining == 10
90
- assert limiter.get_window_stats(limit).reset_time == int(time.time() + 2)
99
+ assert limiter.get_window_stats(limit).reset_time == pytest.approx(
100
+ time.time() + 2, 1e-2
101
+ )
91
102
 
92
103
  @moving_window_storage
93
104
  def test_moving_window_stats(self, uri, args, fixture):
@@ -100,9 +111,9 @@ class TestWindow:
100
111
  time.sleep(1)
101
112
  assert not limiter.hit(limit, "key")
102
113
  assert limiter.get_window_stats(limit, "key").remaining == 0
103
- assert (
104
- limiter.get_window_stats(limit, "key").reset_time - int(time.time()) == 58
105
- )
114
+ assert limiter.get_window_stats(
115
+ limit, "key"
116
+ ).reset_time - time.time() == pytest.approx(58, 1e-2)
106
117
 
107
118
  @moving_window_storage
108
119
  def test_moving_window(self, uri, args, fixture):
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
File without changes
File without changes