1"""
2Async Module
3============
4
5Helper utils for Orange GUI programming.
6
7Provides :func:`asynchronous` decorator for making methods calls in async mode.
8Once method is decorated it will have :func:`task.on_start`, :func:`task.on_result` and :func:`task.callback` decorators for callbacks wrapping.
9
10 - `on_start` must take no arguments
11 - `on_result` must accept one argument (the result)
12 - `callback` can accept any arguments
13
14For instance::
15
16    class Widget(QObject):
17        def __init__(self, name):
18            super().__init__()
19            self.name = name
20
21        @asynchronous
22        def task(self):
23            for i in range(3):
24                time.sleep(0.5)
25                self.report_progress(i)
26            return 'Done'
27
28        @task.on_start
29        def report_start(self):
30            print('`{}` started'.format(self.name))
31
32        @task.on_result
33        def report_result(self, result):
34            print('`{}` result: {}'.format(self.name, result))
35
36        @task.callback
37        def report_progress(self, i):
38            print('`{}` progress: {}'.format(self.name, i))
39
40
41Calling an asynchronous method will launch a daemon thread::
42
43    first = Widget(name='First')
44    first.task()
45    second = Widget(name='Second')
46    second.task()
47
48    first.task.join()
49    second.task.join()
50
51
52A possible output::
53
54    `First` started
55    `Second` started
56    `Second` progress: 0
57    `First` progress: 0
58    `First` progress: 1
59    `Second` progress: 1
60    `First` progress: 2
61    `First` result: Done
62    `Second` progress: 2
63    `Second` result: Done
64
65
66In order to terminate a thread either call :meth:`stop` method or raise :exc:`StopExecution` exception within :meth:`task`::
67
68    first.task.stop()
69
70"""
71
72import threading
73from functools import wraps, partial
74from AnyQt.QtCore import pyqtSlot as Slot, QMetaObject, Qt, Q_ARG, QObject
75
76
77def safe_invoke(obj, method_name, *args):
78    try:
79        QMetaObject.invokeMethod(obj, method_name, Qt.QueuedConnection, *args)
80    except RuntimeError:
81        # C++ object wrapped by `obj` may be already destroyed
82        pass
83
84
85class CallbackMethod:
86    def __init__(self, master, instance):
87        self.instance = instance
88        self.master = master
89
90    def __call__(self, *args, **kwargs):
91        safe_invoke(self.master, 'call', Q_ARG(object, (self.instance, args, kwargs)))
92
93
94class CallbackFunction(QObject):
95    """ PyQt replacement for ordinary function. Will be always called in the main GUI thread (invoked). """
96
97    def __init__(self, func):
98        super().__init__()
99        self.func = func
100
101    @Slot(object)
102    def call(self, scope):
103        instance, args, kwargs = scope
104        try:
105            self.func.__get__(instance, type(instance))(*args, **kwargs)
106        except RuntimeError:
107            # C++ object wrapped by `obj` may be already destroyed
108            pass
109
110    def __get__(self, instance, owner):
111        return CallbackMethod(self, instance)
112
113
114def callback(func):
115    """ Wraps QObject's method and makes its calls always invoked. """
116    return wraps(func)(CallbackFunction(func))
117
118
119class StopExecution(Exception):
120    """ An exception to stop execution of thread's inner cycle. """
121    pass
122
123
124class BoundAsyncMethod(QObject):
125    def __init__(self, func, instance):
126        super().__init__()
127        if isinstance(instance, QObject):
128            instance.destroyed.connect(self.on_destroy)
129
130        self.im_func = func
131        self.im_self = instance
132
133        self.running = False
134        self._thread = None
135
136    def __call__(self, *args, **kwargs):
137        self.stop()
138        self.running = True
139        self._thread = threading.Thread(target=self.run, args=args, kwargs=kwargs,
140                                        daemon=True)
141        self._thread.start()
142
143    def run(self, *args, **kwargs):
144        if self.im_func.start_callback and self.im_self:
145            safe_invoke(self.im_self, self.im_func.start_callback)
146
147        if self.im_self:
148            args = (self.im_self,) + args
149
150        try:
151            result = self.im_func.method(*args, **kwargs)
152        except StopExecution:
153            result = None
154
155        if self.im_func.finish_callback and self.im_self:
156            safe_invoke(self.im_self, self.im_func.finish_callback, Q_ARG(object, result))
157        self.running = False
158
159    def on_destroy(self):
160        self.running = False
161        self.im_self = None
162
163    def stop(self):
164        """ Terminates thread execution. """
165        self.running = False
166        self.join()
167
168    def join(self):
169        """ Waits till task is completed. """
170        if self._thread is not None and self._thread.is_alive():
171            self._thread.join()
172
173    def should_break(self):
174        return not self.running
175
176
177class AsyncMethod(QObject):
178    def __init__(self, method):
179        super().__init__()
180        self.method = method
181        self.method_name = method.__name__
182        self.finish_callback = None
183        self.start_callback = None
184
185    def __get__(self, instance, owner):
186        """ Bounds methods with instance. """
187        bounded = BoundAsyncMethod(self, instance)
188        setattr(instance, self.method.__name__, bounded)
189        return bounded
190
191    def on_start(self, callback):
192        """ On start callback decorator. """
193        self.start_callback = callback.__name__
194        return Slot()(callback)
195
196    def callback(self, method=None, should_raise=True):
197        """ Callback decorator. Add checks for thread state.
198
199        Raises:
200             StopExecution: If thread was stopped (`running = False`).
201        """
202        if method is None:
203            return partial(self.callback, should_raise=should_raise)
204
205        async_method = callback(method)
206
207        @wraps(method)
208        def wrapper(instance, *args, **kwargs):
209            # This check must take place in the background thread.
210            if should_raise and not getattr(instance, self.method_name).running:
211                raise StopExecution
212            # This call must be sent to the main thread.
213            return async_method.__get__(instance, method)(*args, **kwargs)
214
215        return wrapper
216
217    def on_result(self, callback):
218        """ On result callback decorator. """
219        self.finish_callback = callback.__name__
220        return Slot(object)(callback)
221
222
223def asynchronous(task):
224    """ Wraps method of a QObject and replaces it with :class:`AsyncMethod` instance
225    in order to run this method in a separate thread.
226    """
227    return wraps(task)(AsyncMethod(task))
228