throttlebounce 0.1.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.
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0]
10
+
11
+ - First release: `debounce` and `throttle` decorators, thread-safe, with an
12
+ injectable clock and timer so tests never wait on real time.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/throttlebounce/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -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.
@@ -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,102 @@
1
+ # throttlebounce
2
+
3
+ Small, typed, zero-dependency sync debounce and throttle decorators with an injectable clock.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install throttlebounce
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from throttlebounce import debounce, throttle
15
+
16
+
17
+ @debounce(0.3)
18
+ def save(text: str) -> None:
19
+ write_to_disk(text)
20
+
21
+
22
+ # Called on every keystroke, it writes once, 0.3s after typing stops.
23
+ save("h")
24
+ save("he")
25
+ save("hello") # only this one reaches disk
26
+
27
+
28
+ @throttle(1.0)
29
+ def ping() -> None:
30
+ send_heartbeat()
31
+
32
+
33
+ # Called in a tight loop, it fires at most once per second.
34
+ while running:
35
+ ping()
36
+ ```
37
+
38
+ `debounce` collapses a burst of calls into a single run that happens once the
39
+ calls go quiet (trailing edge), with the last call's arguments. The debounced
40
+ callable returns `None`; the real run happens later on a background timer. It
41
+ exposes `flush()` to run the pending call now and return its result, `cancel()`
42
+ to drop it, and a `pending` flag.
43
+
44
+ `throttle` runs the function at most once per interval (leading edge): the first
45
+ call runs, and calls inside the interval are skipped and return the last result.
46
+ `reset()` clears the window.
47
+
48
+ Both decorators are thread-safe. Both take an injectable dependency so tests run
49
+ without real waits: `throttle(interval, clock=...)` and
50
+ `debounce(wait, timer_factory=...)`.
51
+
52
+ ```python
53
+ class FakeClock:
54
+ def __init__(self) -> None:
55
+ self.now = 0.0
56
+
57
+ def __call__(self) -> float:
58
+ return self.now
59
+
60
+
61
+ clock = FakeClock()
62
+
63
+
64
+ @throttle(1.0, clock=clock)
65
+ def tick() -> int:
66
+ return 1
67
+
68
+
69
+ assert tick() == 1 # first call runs
70
+ clock.now = 0.5
71
+ assert tick() == 1 # skipped, returns the last result, no real sleep
72
+ ```
73
+
74
+ ## Why
75
+
76
+ Python's standard library has no debounce or throttle, the timing helpers that
77
+ JavaScript developers reach for constantly. The packages that exist fall short:
78
+
79
+ - [`throttle`](https://pypi.org/project/throttle/) has not been released since
80
+ 2020 and is effectively abandoned.
81
+ - [`throttler`](https://pypi.org/project/throttler/) is an `asyncio` rate limiter,
82
+ not a sync decorator, and does not debounce.
83
+ - [`call-throttle`](https://pypi.org/project/call-throttle/) throttles but has no
84
+ debounce.
85
+ - [`python-debouncer`](https://pypi.org/project/python-debouncer/) debounces but
86
+ pulls in a runtime dependency and has not been touched since 2024.
87
+
88
+ None of them offer a clean, maintained, typed, zero-dependency pair of sync
89
+ `debounce` and `throttle` decorators that are thread-safe and testable without
90
+ real time. That is the gap this fills. Reach for it in desktop apps, CLIs,
91
+ background workers and GUIs: anywhere a function is triggered more often than you
92
+ want it to run.
93
+
94
+ ## License
95
+
96
+ MIT. See [LICENSE](LICENSE).
97
+
98
+ ## Links
99
+
100
+ - Website: https://lianes8studio.com
101
+ - GitHub: https://github.com/JaviLianes8
102
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "throttlebounce"
7
+ version = "0.1.0"
8
+ description = "Small, typed, zero-dependency sync debounce and throttle decorators with an injectable clock"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["debounce", "throttle", "decorator", "rate-limit", "timing", "typed"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://lianes8studio.com"
28
+ Repository = "https://github.com/JaviLianes8/throttlebounce"
29
+ Issues = "https://github.com/JaviLianes8/throttlebounce/issues"
30
+ Changelog = "https://github.com/JaviLianes8/throttlebounce/blob/main/CHANGELOG.md"
31
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
32
+ Funding = "https://buymeacoffee.com/jlianesglrs"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/throttlebounce"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py310"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
43
+
44
+ [tool.mypy]
45
+ python_version = "3.10"
46
+ strict = true
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ pythonpath = ["src"]
51
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,7 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ $ErrorActionPreference = "Stop"
3
+
4
+ ruff format --check src tests
5
+ ruff check src tests
6
+ mypy src tests
7
+ pytest
@@ -0,0 +1,26 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ try {
6
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/throttlebounce/json" | Out-Null
7
+ throw "The name 'throttlebounce' already exists on PyPI. Choose another before publishing."
8
+ } catch [System.Net.WebException], [Microsoft.PowerShell.Commands.HttpResponseException] {
9
+ if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
10
+ }
11
+
12
+ $token = $env:PYPI_TOKEN
13
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
14
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
15
+ }
16
+ if (-not $token) {
17
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
18
+ }
19
+
20
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
21
+ python -m build
22
+ python -m twine check dist/*
23
+
24
+ $env:TWINE_USERNAME = "__token__"
25
+ $env:TWINE_PASSWORD = $token
26
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
@@ -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,33 @@
1
+ import importlib.resources
2
+
3
+ import throttlebounce
4
+
5
+ # The public API. Add every exported name here as the library grows; this list is the
6
+ # contract and the test below fails if the module and this list disagree.
7
+ PUBLIC_API: list[str] = [
8
+ "Clock",
9
+ "Debounced",
10
+ "Throttled",
11
+ "TimerFactory",
12
+ "debounce",
13
+ "throttle",
14
+ ]
15
+
16
+
17
+ def test_all_lists_exactly_the_public_api() -> None:
18
+ assert sorted(throttlebounce.__all__) == sorted(PUBLIC_API)
19
+
20
+
21
+ def test_every_exported_name_resolves() -> None:
22
+ for name in throttlebounce.__all__:
23
+ assert hasattr(throttlebounce, name)
24
+
25
+
26
+ def test_version_is_a_three_part_string() -> None:
27
+ parts = throttlebounce.__version__.split(".")
28
+ assert len(parts) == 3
29
+ assert all(part.isdigit() for part in parts)
30
+
31
+
32
+ def test_the_package_ships_type_information() -> None:
33
+ assert importlib.resources.files("throttlebounce").joinpath("py.typed").is_file()
@@ -0,0 +1,123 @@
1
+ """Behaviour tests for the debounce decorator, with a fake timer (no real waits)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ import pytest
8
+
9
+ from throttlebounce import debounce
10
+
11
+
12
+ class FakeTimer:
13
+ """A one-shot timer that fires only when the test tells it to."""
14
+
15
+ def __init__(self, delay: float, action: Callable[[], None]) -> None:
16
+ self.delay = delay
17
+ self.action = action
18
+ self.cancelled = False
19
+
20
+ def start(self) -> None:
21
+ pass
22
+
23
+ def cancel(self) -> None:
24
+ self.cancelled = True
25
+
26
+ def fire(self) -> None:
27
+ if not self.cancelled:
28
+ self.action()
29
+
30
+
31
+ class TimerSpy:
32
+ """Records every timer it builds so a test can fire the live one."""
33
+
34
+ def __init__(self) -> None:
35
+ self.timers: list[FakeTimer] = []
36
+
37
+ def __call__(self, delay: float, action: Callable[[], None]) -> FakeTimer:
38
+ timer = FakeTimer(delay, action)
39
+ self.timers.append(timer)
40
+ return timer
41
+
42
+
43
+ def test_rapid_calls_collapse_into_one_run_with_the_last_arguments() -> None:
44
+ spy = TimerSpy()
45
+ seen: list[int] = []
46
+
47
+ @debounce(0.5, timer_factory=spy)
48
+ def record(value: int) -> None:
49
+ seen.append(value)
50
+
51
+ record(1)
52
+ record(2)
53
+ record(3)
54
+ assert seen == []
55
+ spy.timers[-1].fire()
56
+ assert seen == [3]
57
+
58
+
59
+ def test_every_earlier_timer_is_cancelled() -> None:
60
+ spy = TimerSpy()
61
+
62
+ @debounce(0.5, timer_factory=spy)
63
+ def noop() -> None:
64
+ pass
65
+
66
+ noop()
67
+ noop()
68
+ noop()
69
+ assert [t.cancelled for t in spy.timers] == [True, True, False]
70
+
71
+
72
+ def test_the_delay_is_passed_to_the_timer() -> None:
73
+ spy = TimerSpy()
74
+
75
+ @debounce(0.25, timer_factory=spy)
76
+ def noop() -> None:
77
+ pass
78
+
79
+ noop()
80
+ assert spy.timers[0].delay == 0.25
81
+
82
+
83
+ def test_cancel_drops_the_pending_call() -> None:
84
+ spy = TimerSpy()
85
+ seen: list[int] = []
86
+
87
+ @debounce(0.5, timer_factory=spy)
88
+ def record(value: int) -> None:
89
+ seen.append(value)
90
+
91
+ record(1)
92
+ record.cancel()
93
+ spy.timers[-1].fire()
94
+ assert seen == []
95
+ assert record.pending is False
96
+
97
+
98
+ def test_flush_runs_the_pending_call_now_and_returns_its_result() -> None:
99
+ spy = TimerSpy()
100
+
101
+ @debounce(0.5, timer_factory=spy)
102
+ def double(value: int) -> int:
103
+ return value * 2
104
+
105
+ double(21)
106
+ assert double.pending is True
107
+ assert double.flush() == 42
108
+ assert double.pending is False
109
+
110
+
111
+ def test_flush_with_nothing_pending_returns_none() -> None:
112
+ spy = TimerSpy()
113
+
114
+ @debounce(0.5, timer_factory=spy)
115
+ def identity(value: int) -> int:
116
+ return value
117
+
118
+ assert identity.flush() is None
119
+
120
+
121
+ def test_a_negative_wait_is_rejected() -> None:
122
+ with pytest.raises(ValueError):
123
+ debounce(-1.0)
@@ -0,0 +1,94 @@
1
+ """Behaviour tests for the throttle decorator, with a fake clock (no real waits)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from throttlebounce import throttle
8
+
9
+
10
+ class FakeClock:
11
+ """A clock the test advances by hand."""
12
+
13
+ def __init__(self) -> None:
14
+ self.now = 0.0
15
+
16
+ def __call__(self) -> float:
17
+ return self.now
18
+
19
+ def advance(self, seconds: float) -> None:
20
+ self.now += seconds
21
+
22
+
23
+ def test_the_first_call_runs_immediately() -> None:
24
+ clock = FakeClock()
25
+ calls: list[int] = []
26
+
27
+ @throttle(1.0, clock=clock)
28
+ def record(value: int) -> int:
29
+ calls.append(value)
30
+ return value
31
+
32
+ assert record(1) == 1
33
+ assert calls == [1]
34
+
35
+
36
+ def test_calls_within_the_interval_are_skipped() -> None:
37
+ clock = FakeClock()
38
+ calls: list[int] = []
39
+
40
+ @throttle(1.0, clock=clock)
41
+ def record(value: int) -> None:
42
+ calls.append(value)
43
+
44
+ record(1)
45
+ clock.advance(0.4)
46
+ record(2)
47
+ clock.advance(0.4)
48
+ record(3)
49
+ assert calls == [1]
50
+
51
+
52
+ def test_a_call_after_the_interval_runs_again() -> None:
53
+ clock = FakeClock()
54
+ calls: list[int] = []
55
+
56
+ @throttle(1.0, clock=clock)
57
+ def record(value: int) -> None:
58
+ calls.append(value)
59
+
60
+ record(1)
61
+ clock.advance(1.0)
62
+ record(2)
63
+ assert calls == [1, 2]
64
+
65
+
66
+ def test_a_skipped_call_returns_the_last_result() -> None:
67
+ clock = FakeClock()
68
+
69
+ @throttle(1.0, clock=clock)
70
+ def double(value: int) -> int:
71
+ return value * 2
72
+
73
+ assert double(10) == 20
74
+ clock.advance(0.5)
75
+ assert double(999) == 20
76
+
77
+
78
+ def test_reset_lets_the_next_call_fire_immediately() -> None:
79
+ clock = FakeClock()
80
+ calls: list[int] = []
81
+
82
+ @throttle(1.0, clock=clock)
83
+ def record(value: int) -> None:
84
+ calls.append(value)
85
+
86
+ record(1)
87
+ record.reset()
88
+ record(2)
89
+ assert calls == [1, 2]
90
+
91
+
92
+ def test_a_negative_interval_is_rejected() -> None:
93
+ with pytest.raises(ValueError):
94
+ throttle(-1.0)