From 0d07fa79f421ea096989a8b414694523f26ce35a Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauldmccarthy@gmail.com> Date: Mon, 21 Oct 2019 15:06:11 +0100 Subject: [PATCH] RF: Reorganised idle loop logic into a single class. Deprecated various module-level functions. No changes to functionality --- fsl/utils/idle.py | 891 +++++++++++++++++++++++------------------- fsl/utils/platform.py | 9 +- 2 files changed, 499 insertions(+), 401 deletions(-) diff --git a/fsl/utils/idle.py b/fsl/utils/idle.py index 7c4c88106..8f2689893 100644 --- a/fsl/utils/idle.py +++ b/fsl/utils/idle.py @@ -8,9 +8,9 @@ asynchronously, either in an idle loop, or on a separate thread. -.. note:: The *idle* functions in this module are intended to be run from - within a ``wx`` application. However, they will still work without - ``wx``, albeit with slightly modified behaviour. +.. note:: The :class:`IdleLoop` functionality in this module is intended to be + run from within a ``wx`` application. However, it will still work + without ``wx``, albeit with slightly modified behaviour. Idle tasks @@ -19,28 +19,20 @@ Idle tasks .. autosummary:: :nosignatures: - block + IdleLoop idle idleWhen - inIdle - cancelIdle - idleReset - getIdleTimeout - setIdleTimeout - + block -The :func:`idle` function is a simple way to run a task on an ``wx`` -``EVT_IDLE`` event handler. This effectively performs the same job as the -:func:`run` function, but is more suitable for short tasks which do not -warrant running in a separate thread. +The :class:`IdleLoop` class provides a simple way to run a task on an ``wx`` +``EVT_IDLE`` event handler. A single ``IdleLoop`` instance is created when +this module is imported; it can be accessed via the :attr:`idleLoop` attribute, +and via the module-level :func:`idle` and :func:`idleWhen` functions. -The ``EVT_IDLE`` event is generated automatically by ``wx``. However, there -are some circumstances in which ``EVT_IDLE`` will not be generated, and -pending events may be left on the queue. For this reason, the -:func:`_wxIdleLoop` will occasionally use a ``wx.Timer`` to ensure that it -continues to be called. The time-out used by this ``Timer`` can be queried -and set via the :func:`getIdleTimeout` and :func:`setIdleTimeout` functions. +The :meth:`IdleLoop.idle` method effectively performs the same job as the +:func:`run` function (described below), but is more suitable for short tasks +which do not warrant running in a separate thread. Thread tasks @@ -58,9 +50,10 @@ The :func:`run` function simply runs a task in a separate thread. This doesn't seem like a worthy task to have a function of its own, but the :func:`run` function additionally provides the ability to schedule another function to run on the ``wx.MainLoop`` when the original function has -completed. This therefore gives us a simple way to run a computationally -intensitve task off the main GUI thread (preventing the GUI from locking up), -and to perform some clean up/refresh afterwards. +completed (via :func:`idle`). This therefore gives us a simple way to run a +computationally intensitve task off the main GUI thread (preventing the GUI +from locking up), and to perform some clean up/refresh/notification +afterwards. The :func:`wait` function is given one or more ``Thread`` instances, and a @@ -91,304 +84,512 @@ from collections import abc try: import queue except ImportError: import Queue as queue +from fsl.utils.deprecated import deprecated + log = logging.getLogger(__name__) -def run(task, onFinish=None, onError=None, name=None): - """Run the given ``task`` in a separate thread. +class IdleTask(object): + """Container object used by the :class:`IdleLoop` class. + Used to encapsulate information about a queued task. + """ - :arg task: The function to run. Must accept no arguments. + def __init__(self, + name, + task, + schedtime, + after, + timeout, + args, + kwargs): + self.name = name + self.task = task + self.schedtime = schedtime + self.after = after + self.timeout = timeout + self.args = args + self.kwargs = kwargs - :arg onFinish: An optional function to schedule (on the ``wx.MainLoop``, - via :func:`idle`) once the ``task`` has finished. - :arg onError: An optional function to be called (on the ``wx.MainLoop``, - via :func:`idle`) if the ``task`` raises an error. Passed - the ``Exception`` that was raised. +class IdleLoop(object): + """This class contains logic for running tasks via ``wx.EVT_IDLE`` events. - :arg name: An optional name to use for this task in log statements. + A single ``IdleLoop`` instance is created when this module is first + imported - it is accessed via the module-level :attr:`idleLoop` attribute. - :returns: A reference to the ``Thread`` that was created. + In normal circumstances, this ``idleLoop`` instance should be treated as a + singleton, although this is not enforced in any way. - .. note:: If a ``wx`` application is not running, the ``task`` and - ``onFinish`` functions will simply be called directly, and - the return value will be ``None``. + The ``EVT_IDLE`` event is generated automatically by ``wx`` during periods + of inactivity. However, there are some circumstances in which ``EVT_IDLE`` + will not be generated, and pending events may be left on the queue. For + this reason, the ``IdleLoop`` will occasionally use a ``wx.Timer`` to + ensure that it continues to be called. The time-out used by this ``Timer`` + can be queried and set via the :meth:`callRate` property. """ - from fsl.utils.platform import platform as fslplatform + def __init__(self): + """Create an ``IdleLoop``. - if name is None: - name = getattr(task, '__name__', '<unknown>') + This method does not do much - the real initialisation takes place + on the first call to :meth:`idle`. + """ + self.__registered = False + self.__queue = queue.Queue() + self.__queueDict = {} + self.__timer = None + self.__callRate = 200 + self.__allowErrors = False + + # Call reset on exit, in case + # the idle.timer is active. + atexit.register(self.reset) + + + @property + def registered(self): + """Boolean flag indicating whether a handler has been registered on + ``wx.EVT_IDLE`` events. Checked and set in the :meth:`idle` method. + """ + return self.__registered - haveWX = fslplatform.haveGui - # Calls the onFinish or onError handler - def callback(cb, *args, **kwargs): + @property + def queue(self): + """A ``Queue`` of functions which are to be run on the ``wx.EVT_IDLE`` + loop. + """ + return self.__queue - if cb is None: - return - if haveWX: idle(cb, *args, **kwargs) - else: cb( *args, **kwargs) + @property + def queueDict(self): + """A ``dict`` containing the names of all named tasks which are + currently queued on the idle loop (see the ``name`` parameter to the + :meth:`idle` method). + """ + return self.__queueDict - # Runs the task, and calls - # callback functions as needed. - def wrapper(): - try: - task() - log.debug('Task "{}" finished'.format(name)) - callback(onFinish) + @property + def timer(self): + """A ``wx.Timer`` instance which is used to periodically trigger the + :func:`_wxIdleLoop` in circumstances where ``wx.EVT_IDLE`` events may + not be generated. This is created in the first call to :meth:`idle`. + """ + return self.__timer - except Exception as e: - log.warning('Task "{}" crashed'.format(name), exc_info=True) - callback(onError, e) + @property + def callRate(self): + """Minimum time (in milliseconds) between consecutive calls to the idle + loop (:meth:`__idleLoop`). If ``wx.EVT_IDLE`` events are not being + fired, the :meth:`timer` is used to maintain the idle loop at this + rate. + """ + return self.__callRate - # If WX, run on a thread - if haveWX: - log.debug('Running task "{}" on thread'.format(name)) + @callRate.setter + def callRate(self, rate): + """Update the :meth:`callRate` to ``rate`` (specified in milliseconds). - thread = threading.Thread(target=wrapper) - thread.start() - return thread + If ``rate is None``, it is set to the default of 200 milliseconds. + """ - # Otherwise run directly - else: - log.debug('Running task "{}" directly'.format(name)) - wrapper() - return None + if rate is None: + rate = 200 + log.debug('Idle loop timeout changed to {}'.format(rate)) -_idleRegistered = False -"""Boolean flag indicating whether the :func:`_wxIdleLoop` function has -been registered as a ``wx.EVT_IDLE`` event handler. Checked and set -in the :func:`idle` function. -""" + self.__callRate = rate -_idleQueue = queue.Queue() -"""A ``Queue`` of functions which are to be run on the ``wx.EVT_IDLE`` -loop. -""" + @property + def allowErrors(self): + """Used for testing/debugging. If ``True``, and a function called on + the idle loop raises an error, that error will not be caught, and the + idle loop will stop. + """ + return self.__allowErrors -_idleQueueDict = {} -"""A ``dict`` containing the names of all named tasks which are -currently queued on the idle loop (see the ``name`` parameter to the -:func:`idle` function). -""" + @allowErrors.setter + def allowErrors(self, allow): + """Update the ``allowErrors`` flag. """ + self.__allowErrors = allow -_idleTimer = None -"""A ``wx.Timer`` instance which is used to periodically trigger the -:func:`_wxIdleLoop` in circumstances where ``wx.EVT_IDLE`` events may not -be generated. This is created in the first call to :func:`idle`. -""" + def reset(self): + """Reset the internal idle loop state. + In a normal execution environment, this method will never need to be + called. However, in an execution environment where multiple ``wx.App`` + instances are created, run, and destroyed sequentially, this function + will need to be called after each ``wx.App`` has been destroyed. + Otherwise the ``idle`` function will not work during exeution of + subsequent ``wx.App`` instances. + """ -_idleCallRate = 200 -"""Minimum time (in milliseconds) between consecutive calls to -:func:`_wxIdleLoop`. If ``wx.EVT_IDLE`` events are not being fired, the -:attr:`_idleTimer` is used to maintain the idle loop at this rate. -""" + if self.__timer is not None: + self.__timer.Stop() + # If we're atexit, the ref to + # the queue module might have + # been cleared, in which case + # we don't want to create a + # new one. + if self.__queue is not None: newQueue = queue.Queue() + else: newQueue = None -_idleAllowErrors = False -"""Used for testing/debugging. If ``True``, and a function called on the idle -loop raises an error, that error will not be caught, and the idle loop will -stop. -""" + self.__registered = False + self.__queue = newQueue + self.__queueDict = {} + self.__timer = None + self.__callRate = 200 + self.__allowErrors = False -def idleReset(): - """Reset the internal :func:`idle` queue state. - - In a normal execution environment, this function will never need to be - called. However, in an execution environment where multiple ``wx.App`` - instances are created, run, and destroyed sequentially, this function - will need to be called after each ``wx.App`` has been destroyed. - Otherwise the ``idle`` function will not work during exeution of - subsequent ``wx.App`` instances. - """ - global _idleRegistered - global _idleQueue - global _idleQueueDict - global _idleTimer - global _idleCallRate - global _idleAllowErrors + def inIdle(self, taskName): + """Returns ``True`` if a task with the given name is queued on the + idle loop (or is currently running), ``False`` otherwise. + """ + return taskName in self.__queueDict - if _idleTimer is not None: - _idleTimer.Stop() - # If we're atexit, the ref - # to the queue module might - # have been cleared. - if queue is not None: newQueue = queue.Queue() - else: newQueue = None + def cancelIdle(self, taskName): + """If a task with the given ``taskName`` is in the idle queue, it + is cancelled. If the task is already running, it cannot be cancelled. - _idleRegistered = False - _idleQueue = newQueue - _idleQueueDict = {} - _idleTimer = None - _idleCallRate = 200 - _idleAllowErrors = False + A ``KeyError`` is raised if no task called ``taskName`` exists. + """ + self.__queueDict[taskName].timeout = -1 -# Call idleReset on exit, in -# case the idleTimer is active. -atexit.register(idleReset) + def idle(self, task, *args, **kwargs): + """Run the given task on a ``wx.EVT_IDLE`` event. + :arg task: The task to run. -def getIdleTimeout(): - """Returns the current ``wx`` idle loop time out/call rate. - """ - return _idleCallRate + :arg name: Optional. If provided, must be provided as a keyword + argument. Specifies a name that can be used to + query the state of this task via :meth:`inIdle`. + :arg after: Optional. If provided, must be provided as a keyword + argument. A time, in seconds, which specifies the + amount of time to wait before running this task + after it has been scheduled. -def setIdleTimeout(timeout=None): - """Set the ``wx`` idle loop time out/call rate. If ``timeout`` is not - provided, or is set to ``None``, the timeout is set to 200 milliseconds. - """ + :arg timeout: Optional. If provided, must be provided as a keyword + argument. Specifies a time out, in seconds. If this + amount of time passes before the function gets + scheduled to be called on the idle loop, the + function is not called, and is dropped from the + queue. - global _idleCallRate + :arg dropIfQueued: Optional. If provided, must be provided as a keyword + argument. If ``True``, and a task with the given + ``name`` is already enqueud, that function is + dropped from the queue, and the new task is + enqueued. Defaults to ``False``. This argument takes + precedence over the ``skipIfQueued`` argument. - if timeout is None: - timeout = 200 + :arg skipIfQueued: Optional. If provided, must be provided as a keyword + argument. If ``True``, and a task with the given + ``name`` is already enqueud, (or is running), the + function is not called. Defaults to ``False``. - log.debug('Idle loop timeout changed to {}'.format(timeout)) + :arg alwaysQueue: Optional. If provided, must be provided as a keyword + argument. If ``True``, and a ``wx.MainLoop`` is not + running, the task is enqueued anyway, under the + assumption that a ``wx.MainLoop`` will be started in + the future. Note that, if ``wx.App`` has not yet + been created, another call to ``idle`` must be made + after the app has been created for the original task + to be executed. If ``wx`` is not available, this + parameter will be ignored, and the task executed + directly. - _idleCallRate = timeout + All other arguments are passed through to the task function. -class IdleTask(object): - """Container object used by the :func:`idle` and :func:`_wxIdleLoop` - functions. - """ - def __init__(self, - name, - task, - schedtime, - after, - timeout, - args, - kwargs): - self.name = name - self.task = task - self.schedtime = schedtime - self.after = after - self.timeout = timeout - self.args = args - self.kwargs = kwargs + If a ``wx.App`` is not running, the ``timeout``, ``name`` and + ``skipIfQueued`` arguments are ignored. Instead, the call will sleep + for ``after`` seconds, and then the ``task`` is called directly. -def _wxIdleLoop(ev): - """Function which is called on ``wx.EVT_IDLE`` events, and occasionally - on ``wx.EVT_TIMER`` events via the :attr:`_idleTimer`. If there - is a function on the :attr:`_idleQueue`, it is popped and called. + .. note:: If the ``after`` argument is used, there is no guarantee that + the task will be executed in the order that it is scheduled. + This is because, if the required time has not elapsed when + the task is popped from the queue, it will be re-queued. - .. note:: The ``wx.EVT_IDLE`` event is only triggered on user interaction - (e.g. mouse movement). This means that a situation may arise - whereby a function is queued via the :func:`idle` function, but - no ``EVT_IDLE`` event gets generated. Therefore, the - :attr:`_idleTimer` object is occasionally used to call this - function as well. - """ + .. note:: If you schedule multiple tasks with the same ``name``, and + you do not use the ``skipIfQueued`` or ``dropIfQueued`` + arguments, all of those tasks will be executed, but you will + only be able to query/cancel the most recently enqueued + task. + + .. note:: You will run into difficulties if you schedule a function + that expects/accepts its own keyword arguments called + ``name``, ``skipIfQueued``, ``dropIfQueued``, ``after``, + ``timeout``, or ``alwaysQueue``. + """ + + from fsl.utils.platform import platform as fslplatform + + schedtime = time.time() + timeout = kwargs.pop('timeout', 0) + after = kwargs.pop('after', 0) + name = kwargs.pop('name', None) + dropIfQueued = kwargs.pop('dropIfQueued', False) + skipIfQueued = kwargs.pop('skipIfQueued', False) + alwaysQueue = kwargs.pop('alwaysQueue', False) + + canHaveGui = fslplatform.canHaveGui + haveGui = fslplatform.haveGui + + # If there is no possibility of a + # gui being available in the future + # (determined by canHaveGui), then + # alwaysQueue is ignored. + alwaysQueue = alwaysQueue and canHaveGui + + # We don't have wx - run the task + # directly/synchronously. + if not (haveGui or alwaysQueue): + time.sleep(after) + log.debug('Running idle task directly') + task(*args, **kwargs) + return + + import wx + app = wx.GetApp() + + # Register on the idle event + # if an app is available + # + # n.b. The 'app is not None' test will + # potentially fail in scenarios where + # multiple wx.Apps have been instantiated, + # as it may return a previously created + # app that is no longer active. + if (not self.registered) and (app is not None): + + log.debug('Registering async idle loop') + app.Bind(wx.EVT_IDLE, self.__idleLoop) + + # We also occasionally use a + # timer to drive the loop, so + # let's register that as well + self.__timer = wx.Timer(app) + self.__timer.Bind(wx.EVT_TIMER, self.__idleLoop) + self.__registered = True + + # A task with the specified + # name is already in the queue + if name is not None and self.inIdle(name): + + # Drop the old task + # with the same name + if dropIfQueued: - import wx - global _idleQueue - global _idleQueueDict - global _idleTimer - global _idleCallRate - global _idleAllowErrors - - ev.Skip() - - try: - task = _idleQueue.get_nowait() - - except queue.Empty: - - # Make sure that we get called periodically, - # if EVT_IDLE decides to stop firing. If - # _idleTimer is None, then idleReset has - # probably been called. - if _idleTimer is not None: - _idleTimer.Start(_idleCallRate, wx.TIMER_ONE_SHOT) - return - - now = time.time() - elapsed = now - task.schedtime - queueSizeOffset = 0 - taskName = task.name - funcName = getattr(task.task, '__name__', '<unknown>') - - if taskName is None: taskName = funcName - else: taskName = '{} [{}]'.format(taskName, funcName) - - # Has enough time elapsed - # since the task was scheduled? - # If not, re-queue the task. - # If this is the only task on the - # queue, the idle loop will be - # called again after - # _idleCallRate millisecs. - if elapsed < task.after: - log.debug('Re-queueing function ({}) on wx idle loop'.format(taskName)) - _idleQueue.put_nowait(task) - queueSizeOffset = 1 - - # Has the task timed out? - elif task.timeout == 0 or (elapsed < task.timeout): - - log.debug('Running function ({}) on wx idle loop'.format(taskName)) + # The cancelIdle function sets the old + # task timeout to -1, so it won't get + # executed. But the task is left in the + # queue, and in the queueDict. + # In the latter, the old task gets + # overwritten with the new task below. + self.cancelIdle(name) + log.debug('Idle task ({}) is already queued - ' + 'dropping the old task'.format(name)) + + # Ignore the new task + # with the same name + elif skipIfQueued: + log.debug('Idle task ({}) is already queued ' + '- skipping it'.format(name)) + return + + log.debug('Scheduling idle task ({}) on wx idle ' + 'loop'.format(getattr(task, '__name__', '<unknown>'))) + + idleTask = IdleTask(name, + task, + schedtime, + after, + timeout, + args, + kwargs) + + self.__queue.put_nowait(idleTask) + + if name is not None: + self.__queueDict[name] = idleTask + + + def idleWhen(self, func, condition, *args, **kwargs): + """Poll the ``condition`` function periodically, and schedule ``func`` + on :meth:`idle` when it returns ``True``. + + :arg func: Function to call. + + :arg condition: Function which returns ``True`` or ``False``. The + ``func`` function is only called when the + ``condition`` function returns ``True``. + + :arg pollTime: Must be passed as a keyword argument. Time (in seconds) + to wait between successive calls to ``when``. Defaults + to ``0.2``. + """ + + pollTime = kwargs.get('pollTime', 0.2) + + if not condition(): + self.idle(self.idleWhen, + func, + condition, + after=pollTime, + *args, + **dict(kwargs)) + else: + kwargs.pop('pollTime', None) + self.idle(func, *args, **kwargs) + + + def __idleLoop(self, ev): + """This method is called on ``wx.EVT_IDLE`` events, and occasionally + on ``wx.EVT_TIMER`` events via the :meth:`timer`. If there + is a function on the :meth:`queue`, it is popped and called. + + .. note:: The ``wx.EVT_IDLE`` event is only triggered on user + interaction (e.g. mouse movement). This means that a + situation may arise whereby a function is queued via the + :meth:`idle` method, but no ``EVT_IDLE`` event gets + generated. Therefore, the :meth:`timer` object is + occasionally used to call this function as well. + """ + + import wx + + ev.Skip() try: - task.task(*task.args, **task.kwargs) - except Exception as e: - log.warning('Idle task {} crashed - {}: {}'.format( - taskName, type(e).__name__, str(e)), exc_info=True) + task = self.__queue.get_nowait() - if _idleAllowErrors: - raise e + except queue.Empty: - if task.name is not None: - try: _idleQueueDict.pop(task.name) - except KeyError: pass + # Make sure that we get called periodically, + # if EVT_IDLE decides to stop firing. If + # self.timer is None, then self.reset has + # probably been called. + if self.__timer is not None: + self.__timer.Start(self.__callRate, wx.TIMER_ONE_SHOT) + return - # More tasks on the queue? - # Request anotherd event - if _idleQueue.qsize() > queueSizeOffset: - ev.RequestMore() + now = time.time() + elapsed = now - task.schedtime + queueSizeOffset = 0 + taskName = task.name + funcName = getattr(task.task, '__name__', '<unknown>') + + if taskName is None: taskName = funcName + else: taskName = '{} [{}]'.format(taskName, funcName) + + # Has enough time elapsed + # since the task was scheduled? + # If not, re-queue the task. + # If this is the only task on the + # queue, the idle loop will be + # called again after + # callRate millisecs. + if elapsed < task.after: + log.debug('Re-queueing function ({}) on ' + 'wx idle loop'.format(taskName)) + self.__queue.put_nowait(task) + queueSizeOffset = 1 + + # Has the task timed out? + elif task.timeout == 0 or (elapsed < task.timeout): + + log.debug('Running function ({}) on wx ' + 'idle loop'.format(taskName)) - # Otherwise use the idle - # timer to make sure that - # the loop keeps ticking - # over - else: - _idleTimer.Start(_idleCallRate, wx.TIMER_ONE_SHOT) + try: + task.task(*task.args, **task.kwargs) + except Exception as e: + log.warning('Idle task {} crashed - {}: {}'.format( + taskName, type(e).__name__, str(e)), exc_info=True) + if self.__allowErrors: + raise e -def inIdle(taskName): - """Returns ``True`` if a task with the given name is queued on the - idle loop (or is currently running), ``False`` otherwise. + if task.name is not None: + try: self.__queueDict.pop(task.name) + except KeyError: pass + + # More tasks on the queue? + # Request anotherd event + if self.__queue.qsize() > queueSizeOffset: + ev.RequestMore() + + # Otherwise use the idle + # timer to make sure that + # the loop keeps ticking + # over + else: + self.__timer.Start(self.__callRate, wx.TIMER_ONE_SHOT) + + +idleLoop = IdleLoop() +"""A singleton :class:`IdleLoop` instance, created when this module is +imported. +""" + + +def idle(*args, **kwargs): + """Equivalent to calling :meth:`IdleLoop.idle` on the ``idleLoop`` + singleton. """ - global _idleQueueDict - return taskName in _idleQueueDict + idleLoop.idle(*args, **kwargs) + + +def idleWhen(*args, **kwargs): + """Equivalent to calling :meth:`IdleLoop.idleWhen` on the ``idleLoop`` + singleton. + """ + idleLoop.idleWhen(*args, **kwargs) + + +@deprecated('2.7.0', '3.0.0', 'Use idleLoop.inIdle instead') +def inIdle(taskName): + """Deprecated - use ``idleLoop.inIdle`` instead. """ + return idleLoop.inIdle(taskName) +@deprecated('2.7.0', '3.0.0', 'Use idleLoop.cancelIdle instead') def cancelIdle(taskName): - """If a task with the given ``taskName`` is in the idle queue, it - is cancelled. If the task is already running, it cannot be cancelled. + """Deprecated - use ``idleLoop.cancelIdle`` instead. """ + return idleLoop.cancelIdle(taskName) + + +@deprecated('2.7.0', '3.0.0', 'Use idleLoop.reset instead') +def idleReset(): + """Deprecated - use ``idleLoop.reset`` instead. """ + return idleLoop.reset() + + +@deprecated('2.7.0', '3.0.0', 'Use idleLoop.callRate instead') +def getIdleTimeout(): + """Deprecated - use ``idleLoop.callRate`` instead. """ + return idleLoop.callRate - A ``KeyError`` is raised if no task called ``taskName`` exists. - """ - global _idleQueueDict - _idleQueueDict[taskName].timeout = -1 +@deprecated('2.7.0', '3.0.0', 'Use idleLoop.callRate instead') +def setIdleTimeout(timeout=None): + """Deprecated - use ``idleLoop.callRate`` instead. """ + idleLoop.callRate = timeout def block(secs, delta=0.01, until=None): @@ -428,181 +629,71 @@ def block(secs, delta=0.01, until=None): break -def idle(task, *args, **kwargs): - """Run the given task on a ``wx.EVT_IDLE`` event. - - :arg task: The task to run. - - :arg name: Optional. If provided, must be provided as a keyword - argument. Specifies a name that can be used to query - the state of this task via the :func:`inIdle` function. - - :arg after: Optional. If provided, must be provided as a keyword - argument. A time, in seconds, which specifies the - amount of time to wait before running this task after - it has been scheduled. - - :arg timeout: Optional. If provided, must be provided as a keyword - argument. Specifies a time out, in seconds. If this - amount of time passes before the function gets - scheduled to be called on the idle loop, the function - is not called, and is dropped from the queue. - - :arg dropIfQueued: Optional. If provided, must be provided as a keyword - argument. If ``True``, and a task with the given - ``name`` is already enqueud, that function is dropped - from the queue, and the new task is enqueued. Defaults - to ``False``. This argument takes precedence over the - ``skipIfQueued`` argument. - - :arg skipIfQueued: Optional. If provided, must be provided as a keyword - argument. If ``True``, and a task with the given - ``name`` is already enqueud, (or is running), the - function is not called. Defaults to ``False``. - - :arg alwaysQueue: Optional. If provided, must be provided as a keyword - argument. If ``True``, and a ``wx.MainLoop`` is not - running, the task is enqueued anyway, under the - assumption that a ``wx.MainLoop`` will be started in - the future. Note that, if ``wx.App`` has not yet been - created, another call to ``idle`` must be made after - the app has been created for the original task to be - executed. If ``wx`` is not available, this parameter - will be ignored, and the task executed directly. - - - All other arguments are passed through to the task function. +def run(task, onFinish=None, onError=None, name=None): + """Run the given ``task`` in a separate thread. + :arg task: The function to run. Must accept no arguments. - If a ``wx.App`` is not running, the ``timeout``, ``name`` and - ``skipIfQueued`` arguments are ignored. Instead, the call will sleep for - ``after`` seconds, and then the ``task`` is called directly. + :arg onFinish: An optional function to schedule (on the ``wx.MainLoop``, + via :func:`idle`) once the ``task`` has finished. + :arg onError: An optional function to be called (on the ``wx.MainLoop``, + via :func:`idle`) if the ``task`` raises an error. Passed + the ``Exception`` that was raised. - .. note:: If the ``after`` argument is used, there is no guarantee that - the task will be executed in the order that it is scheduled. - This is because, if the required time has not elapsed when - the task is popped from the queue, it will be re-queued. + :arg name: An optional name to use for this task in log statements. - .. note:: If you schedule multiple tasks with the same ``name``, and you - do not use the ``skipIfQueued`` or ``dropIfQueued`` arguments, - all of those tasks will be executed, but you will only be able - to query/cancel the most recently enqueued task. + :returns: A reference to the ``Thread`` that was created. - .. note:: You will run into difficulties if you schedule a function that - expects/accepts its own keyword arguments called ``name``, - ``skipIfQueued``, ``dropIfQueued``, ``after``, ``timeout``, or - ``alwaysQueue``. + .. note:: If a ``wx`` application is not running, the ``task`` and + ``onFinish`` functions will simply be called directly, and + the return value will be ``None``. """ from fsl.utils.platform import platform as fslplatform - global _idleRegistered - global _idleTimer - global _idleQueue - global _idleQueueDict - - schedtime = time.time() - timeout = kwargs.pop('timeout', 0) - after = kwargs.pop('after', 0) - name = kwargs.pop('name', None) - dropIfQueued = kwargs.pop('dropIfQueued', False) - skipIfQueued = kwargs.pop('skipIfQueued', False) - alwaysQueue = kwargs.pop('alwaysQueue', False) - - canHaveGui = fslplatform.canHaveGui - haveGui = fslplatform.haveGui - - # If there is no possibility of a - # gui being available in the future, - # then alwaysQueue is ignored. - if haveGui or (alwaysQueue and canHaveGui): - - import wx - app = wx.GetApp() - - # Register on the idle event - # if an app is available - # - # n.b. The 'app is not None' test will - # potentially fail in scenarios where - # multiple wx.Apps have been instantiated, - # as it may return a previously created - # app. - if (not _idleRegistered) and (app is not None): - - log.debug('Registering async idle loop') - - app.Bind(wx.EVT_IDLE, _wxIdleLoop) - - _idleTimer = wx.Timer(app) - _idleRegistered = True - - _idleTimer.Bind(wx.EVT_TIMER, _wxIdleLoop) - - if name is not None and inIdle(name): - - if dropIfQueued: - - # The cancelIdle function sets the old - # task timeout to -1, so it won't get - # executed. But the task is left in the - # _idleQueue, and in the _idleQueueDict. - # In the latter, the old task gets - # overwritten with the new task below. - cancelIdle(name) - log.debug('Idle task ({}) is already queued - ' - 'dropping the old task'.format(name)) - - elif skipIfQueued: - log.debug('Idle task ({}) is already queued ' - '- skipping it'.format(name)) - return + if name is None: + name = getattr(task, '__name__', '<unknown>') - log.debug('Scheduling idle task ({}) on wx idle ' - 'loop'.format(getattr(task, '__name__', '<unknown>'))) + haveWX = fslplatform.haveGui - idleTask = IdleTask(name, - task, - schedtime, - after, - timeout, - args, - kwargs) + # Calls the onFinish or onError handler + def callback(cb, *args, **kwargs): - _idleQueue.put_nowait(idleTask) + if cb is None: + return - if name is not None: - _idleQueueDict[name] = idleTask + if haveWX: idle(cb, *args, **kwargs) + else: cb( *args, **kwargs) - else: - time.sleep(after) - log.debug('Running idle task directly') - task(*args, **kwargs) + # Runs the task, and calls + # callback functions as needed. + def wrapper(): + try: + task() + log.debug('Task "{}" finished'.format(name)) + callback(onFinish) -def idleWhen(func, condition, *args, **kwargs): - """Poll the ``condition`` function periodically, and schedule ``func`` on - :func:`idle` when it returns ``True``. + except Exception as e: - :arg func: Function to call. + log.warning('Task "{}" crashed'.format(name), exc_info=True) + callback(onError, e) - :arg condition: Function which returns ``True`` or ``False``. The ``func`` - function is only called when the ``condition`` function - returns ``True``. + # If WX, run on a thread + if haveWX: - :arg pollTime: Must be passed as a keyword argument. Time (in seconds) to - wait between successive calls to ``when``. Defaults to - ``0.2``. - """ + log.debug('Running task "{}" on thread'.format(name)) - pollTime = kwargs.get('pollTime', 0.2) + thread = threading.Thread(target=wrapper) + thread.start() + return thread - if not condition(): - idle(idleWhen, func, condition, after=pollTime, *args, **dict(kwargs)) + # Otherwise run directly else: - kwargs.pop('pollTime', None) - idle(func, *args, **kwargs) + log.debug('Running task "{}" directly'.format(name)) + wrapper() + return None def wait(threads, task, *args, **kwargs): diff --git a/fsl/utils/platform.py b/fsl/utils/platform.py index 6d3d31eda..1001363dc 100644 --- a/fsl/utils/platform.py +++ b/fsl/utils/platform.py @@ -159,7 +159,14 @@ class Platform(notifier.Notifier): @property def haveGui(self): - """``True`` if we are running with a GUI, ``False`` otherwise. """ + """``True`` if we are running with a GUI, ``False`` otherwise. + + This currently equates to testing whether a display is available + (see :meth:`canHaveGui`) and whether a ``wx.App`` exists. It + previously also tested whether an event loop was running, but this + is not compatible with execution from IPython/Jupyter notebook, where + the event loop is called periodically, and so is not always running. + """ try: import wx app = wx.GetApp() -- GitLab