Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
394 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,241 @@ | ||
# This file is part of Buildbot. Buildbot is free software: you can | ||
# redistribute it and/or modify it under the terms of the GNU General Public | ||
# License as published by the Free Software Foundation, version 2. | ||
# | ||
# This program is distributed in the hope that it will be useful, but WITHOUT | ||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | ||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more | ||
# details. | ||
# | ||
# You should have received a copy of the GNU General Public License along with | ||
# this program; if not, write to the Free Software Foundation, Inc., 51 | ||
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. | ||
# | ||
# Copyright Buildbot Team Members | ||
|
||
from buildbot.util import debounce | ||
from twisted.internet import defer | ||
from twisted.internet import task | ||
from twisted.python import failure | ||
from twisted.python import log | ||
from twisted.trial import unittest | ||
|
||
|
||
class DebouncedClass(object): | ||
|
||
def __init__(self): | ||
self.callDeferred = None | ||
self.calls = 0 | ||
self.expCalls = 0 | ||
self.stopDeferreds = [] | ||
|
||
@debounce.method(wait=4.0) | ||
def maybe(self): | ||
assert not self.callDeferred | ||
self.calls += 1 | ||
log.msg('debounced function called') | ||
self.callDeferred = defer.Deferred() | ||
|
||
@self.callDeferred.addBoth | ||
def unset(x): | ||
log.msg('debounced function complete') | ||
self.callDeferred = None | ||
return x | ||
return self.callDeferred | ||
|
||
|
||
class DebounceTest(unittest.TestCase): | ||
|
||
def setUp(self): | ||
self.clock = task.Clock() | ||
|
||
def scenario(self, events): | ||
dbs = dict((k, DebouncedClass()) | ||
for k in set([n for n, _, _ in events])) | ||
for db in dbs.values(): | ||
db.maybe._reactor = self.clock | ||
while events: | ||
n, t, e = events.pop(0) | ||
db = dbs[n] | ||
log.msg('time=%f, event=%s' % (t, e)) | ||
if t > self.clock.seconds(): | ||
self.clock.advance(t - self.clock.seconds()) | ||
if e == 'maybe': | ||
db.maybe() | ||
elif e == 'called': | ||
db.expCalls += 1 | ||
elif e == 'complete': | ||
db.callDeferred.callback(None) | ||
elif e == 'fail': | ||
db.callDeferred.errback(failure.Failure(RuntimeError())) | ||
elif e == 'failure_logged': | ||
self.assertEqual(len(self.flushLoggedErrors(RuntimeError)), 1) | ||
elif e == 'check': | ||
pass # just check the expCalls | ||
elif e == 'start': | ||
db.maybe.start() | ||
elif e == 'stop': | ||
db.stopDeferreds.append(db.maybe.stop()) | ||
elif e == 'stopNotComplete': | ||
self.assertFalse(db.stopDeferreds[-1].called) | ||
elif e == 'stopComplete': | ||
self.assertTrue(db.stopDeferreds[-1].called) | ||
db.stopDeferreds.pop() | ||
else: | ||
self.fail("unknown scenario event %s" % e) | ||
for db in dbs.values(): | ||
self.assertEqual(db.calls, db.expCalls) | ||
|
||
def test_called_once(self): | ||
"""The debounced method is called only after 4 seconds""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 2.0, 'check'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'check'), | ||
(1, 6.0, 'complete'), | ||
(1, 7.0, 'check') | ||
]) | ||
|
||
def test_coalesce_calls(self): | ||
"""Multiple calls are ecoalesced during 4 seconds, but the function | ||
runs 4 seconds after the first call.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 1.0, 'maybe'), | ||
(1, 2.0, 'maybe'), | ||
(1, 3.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'check'), | ||
(1, 6.0, 'complete'), | ||
(1, 7.0, 'check'), | ||
]) | ||
|
||
def test_second_call_during_first(self): | ||
"""If the debounced method is called after an execution has begun, then | ||
a second execution will take place 4 seconds after the execution | ||
finishes, with intervening calls coalesced.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'maybe'), | ||
(1, 6.0, 'complete'), | ||
(1, 7.0, 'maybe'), | ||
(1, 9.0, 'maybe'), | ||
(1, 10.0, 'called'), | ||
(1, 11.0, 'check'), | ||
]) | ||
|
||
def test_failure_logged(self): | ||
"""If the debounced method fails, the error is logged, but otherwise it | ||
behaves as if it had succeeded.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'maybe'), | ||
(1, 6.0, 'fail'), | ||
(1, 6.0, 'failure_logged'), | ||
(1, 10.0, 'called'), | ||
(1, 11.0, 'check'), | ||
]) | ||
|
||
def test_instance_independence(self): | ||
"""The timers for two instances are independent.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(2, 2.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(2, 6.0, 'called'), | ||
(1, 6.0, 'complete'), | ||
(2, 6.0, 'complete'), | ||
(1, 7.0, 'check'), | ||
]) | ||
|
||
def test_start_when_started(self): | ||
"""Calling meth.start when already started has no effect""" | ||
self.scenario([ | ||
(1, 0.0, 'start'), | ||
(1, 1.0, 'start'), | ||
]) | ||
|
||
def test_stop_while_idle(self): | ||
"""If the debounced method is stopped while idle, subsequent calls do | ||
nothing.""" | ||
self.scenario([ | ||
(1, 0.0, 'stop'), | ||
(1, 0.0, 'stopComplete'), | ||
(1, 1.0, 'maybe'), | ||
(1, 6.0, 'check'), # not called | ||
]) | ||
|
||
def test_stop_while_waiting(self): | ||
"""If the debounced method is stopped while waiting, the waiting call | ||
never occurs, stop returns immediately, and subsequent calls do | ||
nothing.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 2.0, 'stop'), | ||
(1, 2.0, 'stopComplete'), | ||
(1, 3.0, 'maybe'), | ||
(1, 8.0, 'check'), # not called | ||
]) | ||
|
||
def test_stop_while_running(self): | ||
"""If the debounced method is stopped while running, the running call | ||
completes, stop returns only after the call completes, and subsequent | ||
calls do nothing.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'stop'), | ||
(1, 5.0, 'stopNotComplete'), | ||
(1, 6.0, 'complete'), | ||
(1, 6.0, 'stopComplete'), | ||
(1, 6.0, 'maybe'), | ||
(1, 10.0, 'check'), # not called | ||
]) | ||
|
||
def test_multiple_stops(self): | ||
"""Multiple stop calls will return individually when the method | ||
completes.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 5.0, 'stop'), | ||
(1, 5.0, 'stop'), | ||
(1, 5.0, 'stopNotComplete'), | ||
(1, 6.0, 'complete'), | ||
(1, 6.0, 'stopComplete'), | ||
(1, 6.0, 'stopComplete'), | ||
(1, 6.0, 'maybe'), | ||
(1, 10.0, 'check'), # not called | ||
]) | ||
|
||
def test_stop_while_running_queued(self): | ||
"""If the debounced method is stopped while running with another call | ||
queued, the running call completes, stop returns only after the call | ||
completes, the queued call never occurs, and subsequent calls do | ||
nothing.""" | ||
self.scenario([ | ||
(1, 0.0, 'maybe'), | ||
(1, 4.0, 'called'), | ||
(1, 4.5, 'maybe'), | ||
(1, 5.0, 'stop'), | ||
(1, 5.0, 'stopNotComplete'), | ||
(1, 6.0, 'complete'), | ||
(1, 6.0, 'stopComplete'), | ||
(1, 6.0, 'maybe'), | ||
(1, 10.0, 'check'), # not called | ||
]) | ||
|
||
def test_start_after_stop(self): | ||
"""After a stop and subsequent start, a call to the debounced method | ||
causes an invocation 4 seconds later.""" | ||
self.scenario([ | ||
(1, 0.0, 'stop'), | ||
(1, 1.0, 'maybe'), | ||
(1, 2.0, 'start'), | ||
(1, 2.0, 'maybe'), | ||
(1, 5.0, 'check'), # not called | ||
(1, 6.0, 'called'), | ||
]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
# This file is part of Buildbot. Buildbot is free software: you can | ||
# redistribute it and/or modify it under the terms of the GNU General Public | ||
# License as published by the Free Software Foundation, version 2. | ||
# | ||
# This program is distributed in the hope that it will be useful, but WITHOUT | ||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | ||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more | ||
# details. | ||
# | ||
# You should have received a copy of the GNU General Public License along with | ||
# this program; if not, write to the Free Software Foundation, Inc., 51 | ||
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. | ||
# | ||
# Copyright Buildbot Team Members | ||
|
||
import functools | ||
|
||
from twisted.internet import defer | ||
from twisted.internet import reactor | ||
from twisted.python import log | ||
|
||
# debounce phases | ||
PH_IDLE = 0 | ||
PH_WAITING = 1 | ||
PH_RUNNING = 2 | ||
PH_RUNNING_QUEUED = 3 | ||
|
||
|
||
class Debouncer(object): | ||
__slots__ = ['phase', 'timer', 'wait', 'function', 'stopped', | ||
'completeDeferreds', '_reactor'] | ||
|
||
def __init__(self, wait, function): | ||
# time to wait | ||
self.wait = wait | ||
# zero-argument callable to invoke | ||
self.function = function | ||
# current phase | ||
self.phase = PH_IDLE | ||
# Twisted timer for waiting | ||
self.timer = None | ||
# true if this instance is stopped | ||
self.stopped = False | ||
# deferreds to fire when the call is complete | ||
self.completeDeferreds = None | ||
# for tests | ||
self._reactor = reactor | ||
|
||
def __call__(self): | ||
log.msg('invoked, stop=' + str(self.stopped)) | ||
if self.stopped: | ||
return | ||
phase = self.phase | ||
if phase == PH_IDLE: | ||
self.timer = self._reactor.callLater(self.wait, self.invoke) | ||
self.phase = PH_WAITING | ||
elif phase == PH_RUNNING: | ||
self.phase = PH_RUNNING_QUEUED | ||
else: # phase == PH_WAITING or phase == PH_RUNNING_QUEUED: | ||
pass | ||
|
||
def invoke(self): | ||
log.msg('invoke') | ||
self.phase = PH_RUNNING | ||
self.completeDeferreds = [] | ||
d = defer.maybeDeferred(self.function) | ||
d.addErrback(log.err, 'from debounced function:') | ||
|
||
@d.addCallback | ||
def retry(_): | ||
queued = self.phase == PH_RUNNING_QUEUED | ||
self.phase = PH_IDLE | ||
while self.completeDeferreds: | ||
self.completeDeferreds.pop(0).callback(None) | ||
if queued: | ||
self.__call__() | ||
|
||
def start(self): | ||
self.stopped = False | ||
|
||
def stop(self): | ||
self.stopped = True | ||
if self.phase == PH_WAITING: | ||
self.timer.cancel() | ||
self.phase = PH_IDLE | ||
elif self.phase in (PH_RUNNING, PH_RUNNING_QUEUED): | ||
d = defer.Deferred() | ||
self.completeDeferreds.append(d) | ||
return d | ||
return defer.succeed(None) | ||
|
||
|
||
class _Descriptor(object): | ||
|
||
def __init__(self, fn, wait, attrName): | ||
self.fn = fn | ||
self.wait = wait | ||
self.attrName = attrName | ||
|
||
def __get__(self, instance, cls): | ||
try: | ||
db = getattr(instance, self.attrName) | ||
except AttributeError: | ||
db = Debouncer(self.wait, functools.partial(self.fn, instance)) | ||
setattr(instance, self.attrName, db) | ||
return db | ||
|
||
|
||
def method(wait): | ||
def wrap(fn): | ||
stateName = "__debounce_" + fn.__name__ + "__" | ||
return _Descriptor(fn, wait, stateName) | ||
return wrap |
Oops, something went wrong.