1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7# Local imports
8import imp
9import os
10import os.path as osp
11import sys
12import uuid
13
14# Third party imports
15from qtpy.QtCore import (QObject, QProcess, QProcessEnvironment,
16                         QSocketNotifier, QTimer, Signal)
17from qtpy.QtWidgets import QApplication
18import zmq
19
20# Local imports
21from spyder.config.base import debug_print, get_module_path
22
23
24# Heartbeat timer in milliseconds
25HEARTBEAT = 1000
26
27
28class AsyncClient(QObject):
29
30    """
31    A class which handles a connection to a client through a QProcess.
32    """
33
34    # Emitted when the client has initialized.
35    initialized = Signal()
36
37    # Emitted when the client errors.
38    errored = Signal()
39
40    # Emitted when a request response is received.
41    received = Signal(object)
42
43    def __init__(self, target, executable=None, name=None,
44                 extra_args=None, libs=None, cwd=None, env=None):
45        super(AsyncClient, self).__init__()
46        self.executable = executable or sys.executable
47        self.extra_args = extra_args
48        self.target = target
49        self.name = name or self
50        self.libs = libs
51        self.cwd = cwd
52        self.env = env
53        self.is_initialized = False
54        self.closing = False
55        self.notifier = None
56        self.process = None
57        self.context = zmq.Context()
58        QApplication.instance().aboutToQuit.connect(self.close)
59
60        # Set up the heartbeat timer.
61        self.timer = QTimer(self)
62        self.timer.timeout.connect(self._heartbeat)
63
64    def run(self):
65        """Handle the connection with the server.
66        """
67        # Set up the zmq port.
68        self.socket = self.context.socket(zmq.PAIR)
69        self.port = self.socket.bind_to_random_port('tcp://*')
70
71        # Set up the process.
72        self.process = QProcess(self)
73        if self.cwd:
74            self.process.setWorkingDirectory(self.cwd)
75        p_args = ['-u', self.target, str(self.port)]
76        if self.extra_args is not None:
77            p_args += self.extra_args
78
79        # Set up environment variables.
80        processEnvironment = QProcessEnvironment()
81        env = self.process.systemEnvironment()
82        if (self.env and 'PYTHONPATH' not in self.env) or self.env is None:
83            python_path = osp.dirname(get_module_path('spyder'))
84            # Add the libs to the python path.
85            for lib in self.libs:
86                try:
87                    path = osp.dirname(imp.find_module(lib)[1])
88                    python_path = osp.pathsep.join([python_path, path])
89                except ImportError:
90                    pass
91            env.append("PYTHONPATH=%s" % python_path)
92        if self.env:
93            env.update(self.env)
94        for envItem in env:
95            envName, separator, envValue = envItem.partition('=')
96            processEnvironment.insert(envName, envValue)
97        self.process.setProcessEnvironment(processEnvironment)
98
99        # Start the process and wait for started.
100        self.process.start(self.executable, p_args)
101        self.process.finished.connect(self._on_finished)
102        running = self.process.waitForStarted()
103        if not running:
104            raise IOError('Could not start %s' % self)
105
106        # Set up the socket notifer.
107        fid = self.socket.getsockopt(zmq.FD)
108        self.notifier = QSocketNotifier(fid, QSocketNotifier.Read, self)
109        self.notifier.activated.connect(self._on_msg_received)
110
111    def request(self, func_name, *args, **kwargs):
112        """Send a request to the server.
113
114        The response will be a dictionary the 'request_id' and the
115        'func_name' as well as a 'result' field with the object returned by
116        the function call or or an 'error' field with a traceback.
117        """
118        if not self.is_initialized:
119            return
120        request_id = uuid.uuid4().hex
121        request = dict(func_name=func_name,
122                       args=args,
123                       kwargs=kwargs,
124                       request_id=request_id)
125        self._send(request)
126        return request_id
127
128    def close(self):
129        """Cleanly close the connection to the server.
130        """
131        self.closing = True
132        self.is_initialized = False
133        self.timer.stop()
134
135        if self.notifier is not None:
136            self.notifier.activated.disconnect(self._on_msg_received)
137            self.notifier.setEnabled(False)
138            self.notifier = None
139
140        self.request('server_quit')
141
142        if self.process is not None:
143            self.process.waitForFinished(1000)
144            self.process.close()
145        self.context.destroy()
146
147    def _on_finished(self):
148        """Handle a finished signal from the process.
149        """
150        if self.closing:
151            return
152        if self.is_initialized:
153            debug_print('Restarting %s' % self.name)
154            debug_print(self.process.readAllStandardOutput())
155            debug_print(self.process.readAllStandardError())
156            self.is_initialized = False
157            self.notifier.setEnabled(False)
158            self.run()
159        else:
160            debug_print('Errored %s' % self.name)
161            debug_print(self.process.readAllStandardOutput())
162            debug_print(self.process.readAllStandardError())
163            self.errored.emit()
164
165    def _on_msg_received(self):
166        """Handle a message trigger from the socket.
167        """
168        self.notifier.setEnabled(False)
169        while 1:
170            try:
171                resp = self.socket.recv_pyobj(flags=zmq.NOBLOCK)
172            except zmq.ZMQError:
173                self.notifier.setEnabled(True)
174                return
175            if not self.is_initialized:
176                self.is_initialized = True
177                debug_print('Initialized %s' % self.name)
178                self.initialized.emit()
179                self.timer.start(HEARTBEAT)
180                continue
181            resp['name'] = self.name
182            self.received.emit(resp)
183
184    def _heartbeat(self):
185        """Send a heartbeat to keep the server alive.
186        """
187        self._send(dict(func_name='server_heartbeat'))
188
189    def _send(self, obj):
190        """Send an object to the server.
191        """
192        try:
193            self.socket.send_pyobj(obj, zmq.NOBLOCK)
194        except Exception as e:
195            debug_print(e)
196            self.is_initialized = False
197            self._on_finished()
198
199
200class PluginClient(AsyncClient):
201
202    def __init__(self, plugin_name, executable=None, env=None,
203                 extra_path=None):
204        cwd = os.path.dirname(__file__)
205        super(PluginClient, self).__init__(
206                'plugin_server.py',
207                executable=executable, cwd=cwd, env=env,
208                extra_args=[plugin_name], libs=[plugin_name])
209        self.name = plugin_name
210
211
212if __name__ == '__main__':
213    app = QApplication(sys.argv)
214    plugin = PluginClient('jedi')
215    plugin.run()
216
217    def handle_return(value):
218        print(value)  # spyder: test-skip
219        if value['func_name'] == 'foo':
220            app.quit()
221        else:
222            plugin.request('foo')
223
224    def handle_errored():
225        print('errored')  # spyder: test-skip
226        sys.exit(1)
227
228    def start():
229        print('start')  # spyder: test-skip
230        plugin.request('validate')
231
232    plugin.errored.connect(handle_errored)
233    plugin.received.connect(handle_return)
234    plugin.initialized.connect(start)
235
236    app.exec_()
237