Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ Contributors
- Weineel Lee, `weineel@github <https://github.com/weineel>`_
- bl4ckst0ne@github `bl4ckst0ne@github <https://github.com/bl4ckst0ne>`_
- Thomas `DeviousStoat@github <https://github.com/DeviousStoat>`_
- SeaStar Deng, `DSeaStar@github <https://github.com/DSeaStar>`_
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ v8.0.7 (unreleased)
-------------------

- Fix ``in_range`` to support reversed ranges where ``start`` is greater than ``end`` by swapping the bounds, matching lodash's documented ``_.inRange`` behavior (e.g. ``in_range(-3, -2, -6)`` now returns ``True``).
- Fix ``debounce`` to delay execution until after ``wait`` milliseconds of quiet instead of invoking immediately on the first call.


v8.0.6 (2026-01-17)
Expand Down
74 changes: 56 additions & 18 deletions src/pydash/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from functools import cached_property
from inspect import getfullargspec
import itertools
import threading
import time
import typing as t

Expand Down Expand Up @@ -403,30 +404,64 @@ def __init__(

self.last_result: t.Union[T, None] = None

# Initialize last_* times to be prior to the wait periods so that func
# is primed to be executed on first call.
self.last_call = pyd.now() - self.wait
self.last_execution = pyd.now() - max_wait if pyd.is_number(max_wait) else None
self._lock = threading.Lock()
self._timer: t.Optional[threading.Timer] = None
self._args: t.Tuple[t.Any, ...] = ()
self._kwargs: t.Dict[str, t.Any] = {}
self._first_call: t.Optional[int] = None
self._generation = 0

def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
"""
Execute :attr:`func` if function hasn't been called within last :attr:`wait` milliseconds or
in last :attr:`max_wait` milliseconds.
Schedule :attr:`func` to run after :attr:`wait` milliseconds have elapsed since the last
call. If :attr:`max_wait` is set, execute once that many milliseconds have elapsed since the
first call in the current burst.

Return results of last successful call.
"""
present = pyd.now()

if (present - self.last_call) >= self.wait or (
self.max_wait and (present - self.last_execution) >= self.max_wait # type: ignore
):
self.last_result = self.func(*args, **kwargs)
self.last_execution = present

self.last_call = present

# It will be set after first call, cannot be `None` anymore
return self.last_result # type: ignore
with self._lock:
self._args = args
self._kwargs = kwargs
present = pyd.now()

if self._first_call is None:
self._first_call = present

if self._timer is not None:
self._timer.cancel()
self._timer = None

# A call that arrives after max_wait has already elapsed should run now so a burst
# cannot delay execution indefinitely.
if self.max_wait and (present - self._first_call) >= self.max_wait:
self._generation += 1
return self._invoke()

delay_ms = self.wait
if self.max_wait:
remaining_max = self.max_wait - (present - self._first_call)
delay_ms = min(delay_ms, remaining_max)

self._generation += 1
generation = self._generation
self._timer = threading.Timer(max(delay_ms, 0) / 1000.0, self._on_timer, (generation,))
self._timer.daemon = True
self._timer.start()

return self.last_result # type: ignore

def _on_timer(self, generation: int) -> None:
with self._lock:
if generation != self._generation:
return
self._invoke()

def _invoke(self) -> T:
"""Execute ``func`` with the latest arguments. Caller must hold ``_lock``."""
self.last_result = self.func(*self._args, **self._kwargs)
self._first_call = None
self._timer = None
return self.last_result


class Disjoin(t.Generic[T]):
Expand Down Expand Up @@ -891,6 +926,9 @@ def debounce(
Function wrapped in a :class:`Debounce` context.

.. versionadded:: 1.0.0

.. versionchanged:: 8.0.7
Execute ``func`` after ``wait`` milliseconds of quiet instead of on the leading edge.
"""
return Debounce(func, wait, max_wait=max_wait)

Expand Down
61 changes: 36 additions & 25 deletions tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,47 +131,58 @@ def test_curry_right(case, arglist, expected):


def test_debounce():
def func():
return _.now()

wait = 250
debounced = _.debounce(func, wait)
calls = []

start = _.now()
present = _.now()
def func(*args):
calls.append(args)
return args

expected = debounced()
wait = 60
debounced = _.debounce(func, wait)

while (present - start) <= wait + 100:
result = debounced()
present = _.now()
# First call is deferred; subsequent calls reset the wait.
assert debounced(1) is None
time.sleep(0.02)
assert debounced(2) is None
assert calls == []

assert result == expected
time.sleep((wait + 40) / 1000.0)
assert calls == [(2,)]
assert debounced.last_result == (2,)

time.sleep(wait / 1000.0)
result = debounced()
# After a quiet period, a new call is deferred again and returns the last result.
result = debounced(3)
assert result == (2,)
assert calls == [(2,)]

assert result > expected
time.sleep((wait + 40) / 1000.0)
assert calls == [(2,), (3,)]


def test_debounce_max_wait():
calls = []

def func():
return _.now()
now = _.now()
calls.append(now)
return now

wait = 250
max_wait = 300
wait = 200
max_wait = 250
debounced = _.debounce(func, wait, max_wait=max_wait)

start = _.now()
present = _.now()
assert debounced() is None
assert calls == []

expected = debounced()

while (present - start) <= (max_wait + 5):
result = debounced()
present = _.now()
# Keep invoking so `wait` never elapses; `max_wait` should still fire.
deadline = start + max_wait + 80
while _.now() < deadline:
debounced()
time.sleep(0.02)

assert result > expected
assert len(calls) >= 1
assert calls[0] - start >= max_wait - 40


@parametrize(
Expand Down
Loading