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.
@@ -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
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.