9

How could one write a debounce decorator in python which debounces not only on function called but also on the function arguments/combination of function arguments used?

Debouncing means to supress the call to a function within a given timeframe, say you call a function 100 times within 1 second but you only want to allow the function to run once every 10 seconds a debounce decorated function would run the function once 10 seconds after the last function call if no new function calls were made. Here I'm asking how one could debounce a function call with specific function arguments.

An example could be to debounce an expensive update of a person object like:

@debounce(seconds=10)
def update_person(person_id):
    # time consuming, expensive op
    print('>>Updated person {}'.format(person_id))

Then debouncing on the function - including function arguments:

update_person(person_id=144)
update_person(person_id=144)
update_person(person_id=144)
>>Updated person 144

update_person(person_id=144)
update_person(person_id=355)
>>Updated person 144
>>Updated person 355

So calling the function update_person with the same person_id would be supressed (debounced) until the 10 seconds debounce interval has passed without a new call to the function with that same person_id.

There's a few debounce decorators but none includes the function arguments, example: https://gist.github.com/walkermatt/2871026

I've done a similar throttle decorator by function and arguments:

def throttle(s, keep=60):

    def decorate(f):

        caller = {}

        def wrapped(*args, **kwargs):
            nonlocal caller

            called_args = '{}'.format(*args)
            t_ = time.time()

            if caller.get(called_args, None) is None or t_ - caller.get(called_args, 0) >= s:
                result = f(*args, **kwargs)

                caller = {key: val for key, val in caller.items() if t_ - val > keep}
                caller[called_args] = t_
                return result

            # Keep only calls > keep
            caller = {key: val for key, val in caller.items() if t_ - val > keep}
            caller[called_args] = t_

        return wrapped

    return decorate

The main takaway is that it keeps the function arguments in caller[called_args]

See also the difference between throttle and debounce: http://demo.nimius.net/debounce_throttle/

Update:

After some tinkering with the above throttle decorator and the threading.Timer example in the gist, I actually think this should work:

from threading import Timer
from inspect import signature
import time


def debounce(wait):
    def decorator(fn):
        sig = signature(fn)
        caller = {}

        def debounced(*args, **kwargs):
            nonlocal caller

            try:
                bound_args = sig.bind(*args, **kwargs)
                bound_args.apply_defaults()
                called_args = fn.__name__ + str(dict(bound_args.arguments))
            except:
                called_args = ''

            t_ = time.time()

            def call_it(key):
                try:
                    # always remove on call
                    caller.pop(key)
                except:
                    pass

                fn(*args, **kwargs)

            try:
                # Always try to cancel timer
                caller[called_args].cancel()
            except:
                pass

            caller[called_args] = Timer(wait, call_it, [called_args])
            caller[called_args].start()

        return debounced

    return decorator
ehu
  • 91
  • 1
  • 4
  • 2
    Define "debouncing" in this context; expected input and output? Also, your input and output is dependent on time, so you might want to provide that. – Mateen Ulhaq Apr 28 '20 at 09:33
  • Very mildly relevant: http://reactivex.io/documentation/operators/debounce.html – Mateen Ulhaq Apr 28 '20 at 09:35
  • You might want to explain what "debounce" means for those not familiar with keyboard hardware implementation details – rdas Apr 28 '20 at 09:35
  • 1
    What have you tried so far to implement the debounce decorator? You have demonstrated that you know how to write a decorator, and the debounce algorithm is not particularly complicated. The linked gist does show a debounce decorator with arguments. What specific issue do you need help with? – MisterMiyagi Apr 28 '20 at 12:48
  • @MisterMiyagi as I have clarified in the question, it's debouncing on the called functions and its arguments - not the decorator's arguments. The first gist linked above uses threading.Timer to debounce the function call - but how could that be implemented to account for the arguments used in the function call, like the throttle decorator in the question which holds the arguments in caller[called_args] – ehu Apr 28 '20 at 15:16
  • You could use functools lru_cache implementation or customise it further like how cachetools does, which also has an option to timeout. https://cachetools.readthedocs.io/en/stable/ – Luv Apr 28 '20 at 16:23
  • Thanks @mkrieger1, updated the example – ehu Apr 28 '20 at 17:50
  • 1
    I still have very little clue what debouce means here, but I do see something odd in your code that I do understand: The `'{}'.format(*args)` expression almost certainly doesn't do what you want it to do. It's equivalent to `str(args[0])`, I think. If you fall sophisticated argument handling, you probably want to use `inspect.Signature`, it would be very tedious to reinvent it. – Blckknght Apr 28 '20 at 20:09
  • @Blckknght, thank you so much - that's exactly what's needed to get a safe key for any configuration of arguments! Updated the code – ehu Apr 28 '20 at 21:01
  • 2
    @Blckknght; throttle - let first call through supress following, debounce - supress all except the last call. Both throttle and debounce is within a given interval in time. – ehu Apr 28 '20 at 21:36
  • That's a nice example on how to implement debouncing in Python. I'm coming from Node.js, so it was a perfect start for my debouncing algorithm in Python, although I didn't implement it as a decorator, just integrated it straight to my code. – Salivan Mar 04 '21 at 14:29
  • If you found a solution you should post it as an answer, not in the question., – Barmar Jan 24 '23 at 16:14

1 Answers1

6

I've had the same need to build a debounce annotation for a personal project, after stumbling upon the same gist / discussion you have, I ended up with the following solution:

import threading
def debounce(wait_time):
    """
    Decorator that will debounce a function so that it is called after wait_time seconds
    If it is called multiple times, will wait for the last call to be debounced and run only this one.
    """

    def decorator(function):
        def debounced(*args, **kwargs):
            def call_function():
                debounced._timer = None
                return function(*args, **kwargs)
            # if we already have a call to the function currently waiting to be executed, reset the timer
            if debounced._timer is not None:
                debounced._timer.cancel()

            # after wait_time, call the function provided to the decorator with its arguments
            debounced._timer = threading.Timer(wait_time, call_function)
            debounced._timer.start()

        debounced._timer = None
        return debounced

    return decorator

I've created an open-source project to provide functions such as debounce, throttle, filter ... as decorators, contributions are more than welcome to improve on the solution I have for these decorators / add other useful decorators: decorator-operations repository

KarlPatach
  • 63
  • 1
  • 6
  • 1
    This does not seem to work when there are multiple different calls with unique arguments and you want it to debounce them individually. Only the last one is getting through in Python 3.9 when I am testing. – JensB Aug 03 '22 at 21:17