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.
- throttlebounce-0.1.0/.gitattributes +1 -0
- throttlebounce-0.1.0/.github/FUNDING.yml +1 -0
- throttlebounce-0.1.0/.gitignore +12 -0
- throttlebounce-0.1.0/CHANGELOG.md +12 -0
- throttlebounce-0.1.0/CONTRIBUTING.md +19 -0
- throttlebounce-0.1.0/LICENSE +21 -0
- throttlebounce-0.1.0/PKG-INFO +129 -0
- throttlebounce-0.1.0/README.md +102 -0
- throttlebounce-0.1.0/pyproject.toml +51 -0
- throttlebounce-0.1.0/requirements-dev.txt +3 -0
- throttlebounce-0.1.0/scripts/check.ps1 +7 -0
- throttlebounce-0.1.0/scripts/release.ps1 +26 -0
- throttlebounce-0.1.0/src/throttlebounce/__init__.py +17 -0
- throttlebounce-0.1.0/src/throttlebounce/_debounce.py +111 -0
- throttlebounce-0.1.0/src/throttlebounce/_throttle.py +64 -0
- throttlebounce-0.1.0/src/throttlebounce/py.typed +0 -0
- throttlebounce-0.1.0/tests/test_contract.py +33 -0
- throttlebounce-0.1.0/tests/test_debounce.py +123 -0
- throttlebounce-0.1.0/tests/test_throttle.py +94 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buy_me_a_coffee: jlianesglrs
|
|
@@ -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,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)
|