throttlebounce 0.1.0__py3-none-any.whl
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.
- throttlebounce/__init__.py +17 -0
- throttlebounce/_debounce.py +111 -0
- throttlebounce/_throttle.py +64 -0
- throttlebounce/py.typed +0 -0
- throttlebounce-0.1.0.dist-info/METADATA +129 -0
- throttlebounce-0.1.0.dist-info/RECORD +8 -0
- throttlebounce-0.1.0.dist-info/WHEEL +4 -0
- throttlebounce-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Small, typed, zero-dependency sync debounce and throttle decorators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._debounce import Debounced, TimerFactory, debounce
|
|
6
|
+
from ._throttle import Clock, Throttled, throttle
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Clock",
|
|
12
|
+
"Debounced",
|
|
13
|
+
"Throttled",
|
|
14
|
+
"TimerFactory",
|
|
15
|
+
"debounce",
|
|
16
|
+
"throttle",
|
|
17
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Trailing-edge debounce: run a function only after its calls go quiet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Generic, ParamSpec, Protocol, TypeVar
|
|
9
|
+
|
|
10
|
+
_P = ParamSpec("_P")
|
|
11
|
+
_R = TypeVar("_R")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Timer(Protocol):
|
|
15
|
+
"""The slice of threading.Timer the debouncer depends on."""
|
|
16
|
+
|
|
17
|
+
def start(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
def cancel(self) -> None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TimerFactory = Callable[[float, Callable[[], None]], _Timer]
|
|
23
|
+
"""Builds a one-shot timer that runs an action after a delay in seconds.
|
|
24
|
+
|
|
25
|
+
Inject a fake factory in tests so nothing waits on real time.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_timer_factory(delay: float, action: Callable[[], None]) -> _Timer:
|
|
30
|
+
# Daemon so a still-pending call never keeps the interpreter alive on exit.
|
|
31
|
+
timer = threading.Timer(delay, action)
|
|
32
|
+
timer.daemon = True
|
|
33
|
+
return timer
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Debounced(Generic[_P, _R]):
|
|
37
|
+
"""A callable that delays ``func`` until ``wait`` seconds after the last call."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, func: Callable[_P, _R], wait: float, timer_factory: TimerFactory) -> None:
|
|
40
|
+
self._func = func
|
|
41
|
+
self._wait = wait
|
|
42
|
+
self._new_timer = timer_factory
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._timer: _Timer | None = None
|
|
45
|
+
self._pending: functools.partial[_R] | None = None
|
|
46
|
+
self._result: _R | None = None
|
|
47
|
+
functools.update_wrapper(self, func)
|
|
48
|
+
|
|
49
|
+
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
|
50
|
+
call = functools.partial(self._func, *args, **kwargs)
|
|
51
|
+
with self._lock:
|
|
52
|
+
if self._timer is not None:
|
|
53
|
+
self._timer.cancel()
|
|
54
|
+
self._pending = call
|
|
55
|
+
timer = self._new_timer(self._wait, self._fire)
|
|
56
|
+
self._timer = timer
|
|
57
|
+
timer.start()
|
|
58
|
+
|
|
59
|
+
def _fire(self) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
call = self._pending
|
|
62
|
+
self._pending = None
|
|
63
|
+
self._timer = None
|
|
64
|
+
if call is not None:
|
|
65
|
+
self._result = call()
|
|
66
|
+
|
|
67
|
+
def cancel(self) -> None:
|
|
68
|
+
"""Drop any pending call so it will never run."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
if self._timer is not None:
|
|
71
|
+
self._timer.cancel()
|
|
72
|
+
self._timer = None
|
|
73
|
+
self._pending = None
|
|
74
|
+
|
|
75
|
+
def flush(self) -> _R | None:
|
|
76
|
+
"""Run any pending call right now and return its result, else None."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._timer is not None:
|
|
79
|
+
self._timer.cancel()
|
|
80
|
+
self._timer = None
|
|
81
|
+
call = self._pending
|
|
82
|
+
self._pending = None
|
|
83
|
+
if call is None:
|
|
84
|
+
return None
|
|
85
|
+
self._result = call()
|
|
86
|
+
return self._result
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def pending(self) -> bool:
|
|
90
|
+
"""Whether a call is waiting to run."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
return self._pending is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def debounce(
|
|
96
|
+
wait: float, *, timer_factory: TimerFactory = _default_timer_factory
|
|
97
|
+
) -> Callable[[Callable[_P, _R]], Debounced[_P, _R]]:
|
|
98
|
+
"""Return a decorator that debounces a function by ``wait`` seconds.
|
|
99
|
+
|
|
100
|
+
Rapid calls collapse into one: the wrapped function runs ``wait`` seconds
|
|
101
|
+
after the final call, with that call's arguments. The debounced callable
|
|
102
|
+
returns None (the real run happens later); use ``flush`` to force it now,
|
|
103
|
+
``cancel`` to drop it, and ``pending`` to check for a waiting call.
|
|
104
|
+
"""
|
|
105
|
+
if wait < 0:
|
|
106
|
+
raise ValueError("wait must be non-negative")
|
|
107
|
+
|
|
108
|
+
def decorator(func: Callable[_P, _R]) -> Debounced[_P, _R]:
|
|
109
|
+
return Debounced(func, wait, timer_factory)
|
|
110
|
+
|
|
111
|
+
return decorator
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Leading-edge throttle: run a function at most once per interval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Generic, ParamSpec, TypeVar
|
|
10
|
+
|
|
11
|
+
_P = ParamSpec("_P")
|
|
12
|
+
_R = TypeVar("_R")
|
|
13
|
+
|
|
14
|
+
Clock = Callable[[], float]
|
|
15
|
+
"""Returns a monotonically increasing time in seconds.
|
|
16
|
+
|
|
17
|
+
Inject a fake clock in tests so nothing waits on real time.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Throttled(Generic[_P, _R]):
|
|
22
|
+
"""A callable that runs ``func`` at most once per ``interval`` seconds."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, func: Callable[_P, _R], interval: float, clock: Clock) -> None:
|
|
25
|
+
self._func = func
|
|
26
|
+
self._interval = interval
|
|
27
|
+
self._clock = clock
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
self._last: float | None = None
|
|
30
|
+
self._result: _R | None = None
|
|
31
|
+
functools.update_wrapper(self, func)
|
|
32
|
+
|
|
33
|
+
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R | None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
now = self._clock()
|
|
36
|
+
if self._last is not None and now - self._last < self._interval:
|
|
37
|
+
return self._result
|
|
38
|
+
self._last = now
|
|
39
|
+
result = self._func(*args, **kwargs)
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._result = result
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
def reset(self) -> None:
|
|
45
|
+
"""Forget the last run so the next call fires immediately."""
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._last = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def throttle(
|
|
51
|
+
interval: float, *, clock: Clock = time.monotonic
|
|
52
|
+
) -> Callable[[Callable[_P, _R]], Throttled[_P, _R]]:
|
|
53
|
+
"""Return a decorator that throttles a function to once per ``interval`` seconds.
|
|
54
|
+
|
|
55
|
+
The first call runs immediately; any call within ``interval`` of the last
|
|
56
|
+
run is skipped and returns the last result (None before the first run).
|
|
57
|
+
"""
|
|
58
|
+
if interval < 0:
|
|
59
|
+
raise ValueError("interval must be non-negative")
|
|
60
|
+
|
|
61
|
+
def decorator(func: Callable[_P, _R]) -> Throttled[_P, _R]:
|
|
62
|
+
return Throttled(func, interval, clock)
|
|
63
|
+
|
|
64
|
+
return decorator
|
throttlebounce/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: throttlebounce
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small, typed, zero-dependency sync debounce and throttle decorators with an injectable clock
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/throttlebounce
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/throttlebounce/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/throttlebounce/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: debounce,decorator,rate-limit,throttle,timing,typed
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# throttlebounce
|
|
29
|
+
|
|
30
|
+
Small, typed, zero-dependency sync debounce and throttle decorators with an injectable clock.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install throttlebounce
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from throttlebounce import debounce, throttle
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@debounce(0.3)
|
|
45
|
+
def save(text: str) -> None:
|
|
46
|
+
write_to_disk(text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Called on every keystroke, it writes once, 0.3s after typing stops.
|
|
50
|
+
save("h")
|
|
51
|
+
save("he")
|
|
52
|
+
save("hello") # only this one reaches disk
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@throttle(1.0)
|
|
56
|
+
def ping() -> None:
|
|
57
|
+
send_heartbeat()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Called in a tight loop, it fires at most once per second.
|
|
61
|
+
while running:
|
|
62
|
+
ping()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`debounce` collapses a burst of calls into a single run that happens once the
|
|
66
|
+
calls go quiet (trailing edge), with the last call's arguments. The debounced
|
|
67
|
+
callable returns `None`; the real run happens later on a background timer. It
|
|
68
|
+
exposes `flush()` to run the pending call now and return its result, `cancel()`
|
|
69
|
+
to drop it, and a `pending` flag.
|
|
70
|
+
|
|
71
|
+
`throttle` runs the function at most once per interval (leading edge): the first
|
|
72
|
+
call runs, and calls inside the interval are skipped and return the last result.
|
|
73
|
+
`reset()` clears the window.
|
|
74
|
+
|
|
75
|
+
Both decorators are thread-safe. Both take an injectable dependency so tests run
|
|
76
|
+
without real waits: `throttle(interval, clock=...)` and
|
|
77
|
+
`debounce(wait, timer_factory=...)`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
class FakeClock:
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.now = 0.0
|
|
83
|
+
|
|
84
|
+
def __call__(self) -> float:
|
|
85
|
+
return self.now
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
clock = FakeClock()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@throttle(1.0, clock=clock)
|
|
92
|
+
def tick() -> int:
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
assert tick() == 1 # first call runs
|
|
97
|
+
clock.now = 0.5
|
|
98
|
+
assert tick() == 1 # skipped, returns the last result, no real sleep
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Why
|
|
102
|
+
|
|
103
|
+
Python's standard library has no debounce or throttle, the timing helpers that
|
|
104
|
+
JavaScript developers reach for constantly. The packages that exist fall short:
|
|
105
|
+
|
|
106
|
+
- [`throttle`](https://pypi.org/project/throttle/) has not been released since
|
|
107
|
+
2020 and is effectively abandoned.
|
|
108
|
+
- [`throttler`](https://pypi.org/project/throttler/) is an `asyncio` rate limiter,
|
|
109
|
+
not a sync decorator, and does not debounce.
|
|
110
|
+
- [`call-throttle`](https://pypi.org/project/call-throttle/) throttles but has no
|
|
111
|
+
debounce.
|
|
112
|
+
- [`python-debouncer`](https://pypi.org/project/python-debouncer/) debounces but
|
|
113
|
+
pulls in a runtime dependency and has not been touched since 2024.
|
|
114
|
+
|
|
115
|
+
None of them offer a clean, maintained, typed, zero-dependency pair of sync
|
|
116
|
+
`debounce` and `throttle` decorators that are thread-safe and testable without
|
|
117
|
+
real time. That is the gap this fills. Reach for it in desktop apps, CLIs,
|
|
118
|
+
background workers and GUIs: anywhere a function is triggered more often than you
|
|
119
|
+
want it to run.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT. See [LICENSE](LICENSE).
|
|
124
|
+
|
|
125
|
+
## Links
|
|
126
|
+
|
|
127
|
+
- Website: https://lianes8studio.com
|
|
128
|
+
- GitHub: https://github.com/JaviLianes8
|
|
129
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
throttlebounce/__init__.py,sha256=I5KKJ9Axp5CnPUM11AxokJ9w5ZteNedubNckiPk21OE,356
|
|
2
|
+
throttlebounce/_debounce.py,sha256=YMfa1pWV7-wFG8pHjwCoQ8pwgA79_Jae8ap9yfv7HZU,3610
|
|
3
|
+
throttlebounce/_throttle.py,sha256=NuMPxLnyF3B5HUbIrlo4dd6o4bOWz2WZ9t9Pq2dshNE,2023
|
|
4
|
+
throttlebounce/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
throttlebounce-0.1.0.dist-info/METADATA,sha256=Oc5sUrf5Zj1svVJye0ukXh7KEB65CIa9imUh3UjeuZU,4000
|
|
6
|
+
throttlebounce-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
throttlebounce-0.1.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
8
|
+
throttlebounce-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|