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"""
8Client widget for the IPython Console
9
10This is the widget used on all its tabs
11"""
12
13# Standard library imports
14from __future__ import absolute_import  # Fix for Issue 1356
15
16import codecs
17import os
18import os.path as osp
19from string import Template
20from threading import Thread
21from time import ctime, time, strftime, gmtime
22
23# Third party imports (qtpy)
24from qtpy.QtCore import QUrl, QTimer, Signal, Slot
25from qtpy.QtGui import QKeySequence
26from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMenu, QMessageBox,
27                            QToolButton, QVBoxLayout, QWidget)
28
29# Local imports
30from spyder.config.base import _, get_image_path, get_module_source_path
31from spyder.config.gui import get_font, get_shortcut
32from spyder.utils import icon_manager as ima
33from spyder.utils import sourcecode
34from spyder.utils.encoding import get_coding
35from spyder.utils.environ import RemoteEnvDialog
36from spyder.utils.ipython.style import create_qss_style
37from spyder.utils.programs import TEMPDIR
38from spyder.utils.qthelpers import (add_actions, create_action,
39                                    create_toolbutton, DialogManager,
40                                    MENU_SEPARATOR)
41from spyder.py3compat import to_text_string
42from spyder.widgets.browser import WebView
43from spyder.widgets.ipythonconsole import ShellWidget
44from spyder.widgets.mixins import SaveHistoryMixin
45from spyder.widgets.variableexplorer.collectionseditor import CollectionsEditor
46
47
48#-----------------------------------------------------------------------------
49# Templates
50#-----------------------------------------------------------------------------
51# Using the same css file from the Help plugin for now. Maybe
52# later it'll be a good idea to create a new one.
53UTILS_PATH = get_module_source_path('spyder', 'utils')
54CSS_PATH = osp.join(UTILS_PATH, 'help', 'static', 'css')
55TEMPLATES_PATH = osp.join(UTILS_PATH, 'ipython', 'templates')
56
57BLANK = open(osp.join(TEMPLATES_PATH, 'blank.html')).read()
58LOADING = open(osp.join(TEMPLATES_PATH, 'loading.html')).read()
59KERNEL_ERROR = open(osp.join(TEMPLATES_PATH, 'kernel_error.html')).read()
60
61
62#-----------------------------------------------------------------------------
63# Auxiliary functions
64#-----------------------------------------------------------------------------
65def background(f):
66    """
67    Call a function in a simple thread, to prevent blocking
68
69    Taken from the Jupyter Qtconsole project
70    """
71    t = Thread(target=f)
72    t.start()
73    return t
74
75
76#-----------------------------------------------------------------------------
77# Client widget
78#-----------------------------------------------------------------------------
79class ClientWidget(QWidget, SaveHistoryMixin):
80    """
81    Client widget for the IPython Console
82
83    This is a widget composed of a shell widget and a WebView info widget
84    to print different messages there.
85    """
86
87    SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, ctime())
88    INITHISTORY = ['# -*- coding: utf-8 -*-',
89                   '# *** Spyder Python Console History Log ***',]
90
91    append_to_history = Signal(str, str)
92
93    def __init__(self, plugin, id_,
94                 history_filename, config_options,
95                 additional_options, interpreter_versions,
96                 connection_file=None, hostname=None,
97                 menu_actions=None, slave=False,
98                 external_kernel=False, given_name=None,
99                 show_elapsed_time=False,
100                 reset_warning=True):
101        super(ClientWidget, self).__init__(plugin)
102        SaveHistoryMixin.__init__(self, history_filename)
103
104        # --- Init attrs
105        self.id_ = id_
106        self.connection_file = connection_file
107        self.hostname = hostname
108        self.menu_actions = menu_actions
109        self.slave = slave
110        self.external_kernel = external_kernel
111        self.given_name = given_name
112        self.show_elapsed_time = show_elapsed_time
113        self.reset_warning = reset_warning
114
115        # --- Other attrs
116        self.options_button = None
117        self.stop_button = None
118        self.reset_button = None
119        self.stop_icon = ima.icon('stop')
120        self.history = []
121        self.allow_rename = True
122        self.stderr_dir = None
123
124        # --- Widgets
125        self.shellwidget = ShellWidget(config=config_options,
126                                       ipyclient=self,
127                                       additional_options=additional_options,
128                                       interpreter_versions=interpreter_versions,
129                                       external_kernel=external_kernel,
130                                       local_kernel=True)
131        self.infowidget = WebView(self)
132        self.set_infowidget_font()
133        self.loading_page = self._create_loading_page()
134        self._show_loading_page()
135
136        # Elapsed time
137        self.time_label = None
138        self.t0 = None
139        self.timer = QTimer(self)
140
141        # --- Layout
142        vlayout = QVBoxLayout()
143        toolbar_buttons = self.get_toolbar_buttons()
144
145        hlayout = QHBoxLayout()
146        hlayout.addWidget(self.create_time_label())
147        hlayout.addStretch(0)
148        for button in toolbar_buttons:
149            hlayout.addWidget(button)
150
151        vlayout.addLayout(hlayout)
152        vlayout.setContentsMargins(0, 0, 0, 0)
153        vlayout.addWidget(self.shellwidget)
154        vlayout.addWidget(self.infowidget)
155        self.setLayout(vlayout)
156
157        # --- Exit function
158        self.exit_callback = lambda: plugin.close_client(client=self)
159
160        # --- Dialog manager
161        self.dialog_manager = DialogManager()
162
163        # Show timer
164        self.update_time_label_visibility()
165
166    #------ Public API --------------------------------------------------------
167    @property
168    def kernel_id(self):
169        """Get kernel id"""
170        if self.connection_file is not None:
171            json_file = osp.basename(self.connection_file)
172            return json_file.split('.json')[0]
173
174    @property
175    def stderr_file(self):
176        """Filename to save kernel stderr output."""
177        stderr_file = None
178        if self.connection_file is not None:
179            stderr_file = self.kernel_id + '.stderr'
180            if self.stderr_dir is not None:
181                stderr_file = osp.join(self.stderr_dir, stderr_file)
182            else:
183                try:
184                    if not osp.isdir(TEMPDIR):
185                        os.makedirs(TEMPDIR)
186                    stderr_file = osp.join(TEMPDIR, stderr_file)
187                except (IOError, OSError):
188                    stderr_file = None
189        return stderr_file
190
191    def configure_shellwidget(self, give_focus=True):
192        """Configure shellwidget after kernel is started"""
193        if give_focus:
194            self.get_control().setFocus()
195
196        # Set exit callback
197        self.shellwidget.set_exit_callback()
198
199        # To save history
200        self.shellwidget.executing.connect(self.add_to_history)
201
202        # For Mayavi to run correctly
203        self.shellwidget.executing.connect(
204            self.shellwidget.set_backend_for_mayavi)
205
206        # To update history after execution
207        self.shellwidget.executed.connect(self.update_history)
208
209        # To update the Variable Explorer after execution
210        self.shellwidget.executed.connect(
211            self.shellwidget.refresh_namespacebrowser)
212
213        # To enable the stop button when executing a process
214        self.shellwidget.executing.connect(self.enable_stop_button)
215
216        # To disable the stop button after execution stopped
217        self.shellwidget.executed.connect(self.disable_stop_button)
218
219        # To show kernel restarted/died messages
220        self.shellwidget.sig_kernel_restarted.connect(
221            self.kernel_restarted_message)
222
223        # To correctly change Matplotlib backend interactively
224        self.shellwidget.executing.connect(
225            self.shellwidget.change_mpl_backend)
226
227        # To show env and sys.path contents
228        self.shellwidget.sig_show_syspath.connect(self.show_syspath)
229        self.shellwidget.sig_show_env.connect(self.show_env)
230
231        # To sync with working directory toolbar
232        self.shellwidget.executed.connect(self.shellwidget.get_cwd)
233
234        # To apply style
235        self.set_color_scheme(self.shellwidget.syntax_style, reset=False)
236
237        # To hide the loading page
238        self.shellwidget.sig_prompt_ready.connect(self._hide_loading_page)
239
240        # Show possible errors when setting Matplotlib backend
241        self.shellwidget.sig_prompt_ready.connect(
242            self._show_mpl_backend_errors)
243
244    def enable_stop_button(self):
245        self.stop_button.setEnabled(True)
246
247    def disable_stop_button(self):
248        self.stop_button.setDisabled(True)
249
250    @Slot()
251    def stop_button_click_handler(self):
252        """Method to handle what to do when the stop button is pressed"""
253        self.stop_button.setDisabled(True)
254        # Interrupt computations or stop debugging
255        if not self.shellwidget._reading:
256            self.interrupt_kernel()
257        else:
258            self.shellwidget.write_to_stdin('exit')
259
260    def show_kernel_error(self, error):
261        """Show kernel initialization errors in infowidget."""
262        # Replace end of line chars with <br>
263        eol = sourcecode.get_eol_chars(error)
264        if eol:
265            error = error.replace(eol, '<br>')
266
267        # Don't break lines in hyphens
268        # From http://stackoverflow.com/q/7691569/438386
269        error = error.replace('-', '&#8209')
270
271        # Create error page
272        message = _("An error ocurred while starting the kernel")
273        kernel_error_template = Template(KERNEL_ERROR)
274        page = kernel_error_template.substitute(css_path=CSS_PATH,
275                                                message=message,
276                                                error=error)
277
278        # Show error
279        self.infowidget.setHtml(page)
280        self.shellwidget.hide()
281        self.infowidget.show()
282
283    def get_name(self):
284        """Return client name"""
285        if self.given_name is None:
286            # Name according to host
287            if self.hostname is None:
288                name = _("Console")
289            else:
290                name = self.hostname
291            # Adding id to name
292            client_id = self.id_['int_id'] + u'/' + self.id_['str_id']
293            name = name + u' ' + client_id
294        else:
295            name = self.given_name + u'/' + self.id_['str_id']
296        return name
297
298    def get_control(self):
299        """Return the text widget (or similar) to give focus to"""
300        # page_control is the widget used for paging
301        page_control = self.shellwidget._page_control
302        if page_control and page_control.isVisible():
303            return page_control
304        else:
305            return self.shellwidget._control
306
307    def get_kernel(self):
308        """Get kernel associated with this client"""
309        return self.shellwidget.kernel_manager
310
311    def get_options_menu(self):
312        """Return options menu"""
313        reset_action = create_action(self, _("Remove all variables"),
314                                     icon=ima.icon('editdelete'),
315                                     triggered=self.reset_namespace)
316
317        self.show_time_action = create_action(self, _("Show elapsed time"),
318                                         toggled=self.set_elapsed_time_visible)
319
320        env_action = create_action(
321                        self,
322                        _("Show environment variables"),
323                        icon=ima.icon('environ'),
324                        triggered=self.shellwidget.get_env
325                     )
326
327        syspath_action = create_action(
328                            self,
329                            _("Show sys.path contents"),
330                            icon=ima.icon('syspath'),
331                            triggered=self.shellwidget.get_syspath
332                         )
333
334        self.show_time_action.setChecked(self.show_elapsed_time)
335        additional_actions = [reset_action,
336                              MENU_SEPARATOR,
337                              env_action,
338                              syspath_action,
339                              self.show_time_action]
340
341        if self.menu_actions is not None:
342            return self.menu_actions + additional_actions
343        else:
344            return additional_actions
345
346    def get_toolbar_buttons(self):
347        """Return toolbar buttons list."""
348        buttons = []
349
350        # Code to add the stop button
351        if self.stop_button is None:
352            self.stop_button = create_toolbutton(
353                                   self,
354                                   text=_("Stop"),
355                                   icon=self.stop_icon,
356                                   tip=_("Stop the current command"))
357            self.disable_stop_button()
358            # set click event handler
359            self.stop_button.clicked.connect(self.stop_button_click_handler)
360        if self.stop_button is not None:
361            buttons.append(self.stop_button)
362
363        # Reset namespace button
364        if self.reset_button is None:
365            self.reset_button = create_toolbutton(
366                                    self,
367                                    text=_("Remove"),
368                                    icon=ima.icon('editdelete'),
369                                    tip=_("Remove all variables"),
370                                    triggered=self.reset_namespace)
371        if self.reset_button is not None:
372            buttons.append(self.reset_button)
373
374        if self.options_button is None:
375            options = self.get_options_menu()
376            if options:
377                self.options_button = create_toolbutton(self,
378                        text=_('Options'), icon=ima.icon('tooloptions'))
379                self.options_button.setPopupMode(QToolButton.InstantPopup)
380                menu = QMenu(self)
381                add_actions(menu, options)
382                self.options_button.setMenu(menu)
383        if self.options_button is not None:
384            buttons.append(self.options_button)
385
386        return buttons
387
388    def add_actions_to_context_menu(self, menu):
389        """Add actions to IPython widget context menu"""
390        inspect_action = create_action(self, _("Inspect current object"),
391                                    QKeySequence(get_shortcut('console',
392                                                    'inspect current object')),
393                                    icon=ima.icon('MessageBoxInformation'),
394                                    triggered=self.inspect_object)
395
396        clear_line_action = create_action(self, _("Clear line or block"),
397                                          QKeySequence(get_shortcut(
398                                                  'console',
399                                                  'clear line')),
400                                          triggered=self.clear_line)
401
402        reset_namespace_action = create_action(self, _("Remove all variables"),
403                                               QKeySequence(get_shortcut(
404                                                       'ipython_console',
405                                                       'reset namespace')),
406                                               icon=ima.icon('editdelete'),
407                                               triggered=self.reset_namespace)
408
409        clear_console_action = create_action(self, _("Clear console"),
410                                             QKeySequence(get_shortcut('console',
411                                                               'clear shell')),
412                                             triggered=self.clear_console)
413
414        quit_action = create_action(self, _("&Quit"), icon=ima.icon('exit'),
415                                    triggered=self.exit_callback)
416
417        add_actions(menu, (None, inspect_action, clear_line_action,
418                           clear_console_action, reset_namespace_action,
419                           None, quit_action))
420        return menu
421
422    def set_font(self, font):
423        """Set IPython widget's font"""
424        self.shellwidget._control.setFont(font)
425        self.shellwidget.font = font
426
427    def set_infowidget_font(self):
428        """Set font for infowidget"""
429        font = get_font(option='rich_font')
430        self.infowidget.set_font(font)
431
432    def set_color_scheme(self, color_scheme, reset=True):
433        """Set IPython color scheme."""
434        self.shellwidget.set_color_scheme(color_scheme, reset)
435
436    def shutdown(self):
437        """Shutdown kernel"""
438        if self.get_kernel() is not None and not self.slave:
439            self.shellwidget.kernel_manager.shutdown_kernel()
440        if self.shellwidget.kernel_client is not None:
441            background(self.shellwidget.kernel_client.stop_channels)
442
443    def interrupt_kernel(self):
444        """Interrupt the associanted Spyder kernel if it's running"""
445        # Needed to prevent a crash when a kernel is not running.
446        # See issue 6299
447        try:
448            self.shellwidget.request_interrupt_kernel()
449        except RuntimeError:
450            pass
451
452    @Slot()
453    def restart_kernel(self):
454        """
455        Restart the associated kernel.
456
457        Took this code from the qtconsole project
458        Licensed under the BSD license
459        """
460        sw = self.shellwidget
461
462        message = _('Are you sure you want to restart the kernel?')
463        buttons = QMessageBox.Yes | QMessageBox.No
464        result = QMessageBox.question(self, _('Restart kernel?'),
465                                      message, buttons)
466
467        if result == QMessageBox.Yes:
468            if sw.kernel_manager:
469                if self.infowidget.isVisible():
470                    self.infowidget.hide()
471                    sw.show()
472                try:
473                    sw.kernel_manager.restart_kernel()
474                except RuntimeError as e:
475                    sw._append_plain_text(
476                        _('Error restarting kernel: %s\n') % e,
477                        before_prompt=True
478                    )
479                else:
480                    # For issue 6235.  IPython was changing the setting of
481                    # %colors on windows by assuming it was using a dark
482                    # background.  This corrects it based on the scheme.
483                    self.set_color_scheme(sw.syntax_style)
484                    sw._append_html(_("<br>Restarting kernel...\n<hr><br>"),
485                                    before_prompt=False)
486            else:
487                sw._append_plain_text(
488                    _('Cannot restart a kernel not started by Spyder\n'),
489                    before_prompt=True
490                )
491
492    @Slot(str)
493    def kernel_restarted_message(self, msg):
494        """Show kernel restarted/died messages."""
495        try:
496            stderr = codecs.open(self.stderr_file, 'r',
497                                 encoding='utf-8').read()
498        except UnicodeDecodeError:
499            # This is needed since the stderr file could be encoded
500            # in something different to utf-8.
501            # See issue 4191
502            try:
503                stderr = self._read_stderr()
504            except:
505                stderr = None
506        except (OSError, IOError):
507            stderr = None
508
509        if stderr:
510            self.show_kernel_error('<tt>%s</tt>' % stderr)
511        else:
512            self.shellwidget._append_html("<br>%s<hr><br>" % msg,
513                                          before_prompt=False)
514
515
516    @Slot()
517    def inspect_object(self):
518        """Show how to inspect an object with our Help plugin"""
519        self.shellwidget._control.inspect_current_object()
520
521    @Slot()
522    def clear_line(self):
523        """Clear a console line"""
524        self.shellwidget._keyboard_quit()
525
526    @Slot()
527    def clear_console(self):
528        """Clear the whole console"""
529        self.shellwidget.clear_console()
530
531    @Slot()
532    def reset_namespace(self):
533        """Resets the namespace by removing all names defined by the user"""
534        self.shellwidget.reset_namespace(warning=self.reset_warning,
535                                         message=True)
536
537    def update_history(self):
538        self.history = self.shellwidget._history
539
540    @Slot(object)
541    def show_syspath(self, syspath):
542        """Show sys.path contents."""
543        if syspath is not None:
544            editor = CollectionsEditor()
545            editor.setup(syspath, title="sys.path contents", readonly=True,
546                         width=600, icon=ima.icon('syspath'))
547            self.dialog_manager.show(editor)
548        else:
549            return
550
551    @Slot(object)
552    def show_env(self, env):
553        """Show environment variables."""
554        self.dialog_manager.show(RemoteEnvDialog(env))
555
556    def create_time_label(self):
557        """Create elapsed time label widget (if necessary) and return it"""
558        if self.time_label is None:
559            self.time_label = QLabel()
560        return self.time_label
561
562    def show_time(self, end=False):
563        """Text to show in time_label."""
564        if self.time_label is None:
565            return
566        elapsed_time = time() - self.t0
567        if elapsed_time > 24 * 3600: # More than a day...!
568            fmt = "%d %H:%M:%S"
569        else:
570            fmt = "%H:%M:%S"
571        if end:
572            color = "#AAAAAA"
573        else:
574            color = "#AA6655"
575        text = "<span style=\'color: %s\'><b>%s" \
576               "</b></span>" % (color, strftime(fmt, gmtime(elapsed_time)))
577        self.time_label.setText(text)
578
579    def update_time_label_visibility(self):
580        """Update elapsed time visibility."""
581        self.time_label.setVisible(self.show_elapsed_time)
582
583    @Slot(bool)
584    def set_elapsed_time_visible(self, state):
585        """Slot to show/hide elapsed time label."""
586        self.show_elapsed_time = state
587        if self.time_label is not None:
588            self.time_label.setVisible(state)
589
590    #------ Private API -------------------------------------------------------
591    def _create_loading_page(self):
592        """Create html page to show while the kernel is starting"""
593        loading_template = Template(LOADING)
594        loading_img = get_image_path('loading_sprites.png')
595        if os.name == 'nt':
596            loading_img = loading_img.replace('\\', '/')
597        message = _("Connecting to kernel...")
598        page = loading_template.substitute(css_path=CSS_PATH,
599                                           loading_img=loading_img,
600                                           message=message)
601        return page
602
603    def _show_loading_page(self):
604        """Show animation while the kernel is loading."""
605        self.shellwidget.hide()
606        self.infowidget.show()
607        self.infowidget.setHtml(self.loading_page,
608                                QUrl.fromLocalFile(CSS_PATH))
609
610    def _hide_loading_page(self):
611        """Hide animation shown while the kernel is loading."""
612        self.infowidget.hide()
613        self.shellwidget.show()
614        self.infowidget.setHtml(BLANK)
615        self.shellwidget.sig_prompt_ready.disconnect(self._hide_loading_page)
616
617    def _read_stderr(self):
618        """Read the stderr file of the kernel."""
619        stderr_text = open(self.stderr_file, 'rb').read()
620        encoding = get_coding(stderr_text)
621        stderr = to_text_string(stderr_text, encoding)
622        return stderr
623
624    def _show_mpl_backend_errors(self):
625        """
626        Show possible errors when setting the selected Matplotlib backend.
627        """
628        if not self.external_kernel:
629            self.shellwidget.silent_execute(
630                    "get_ipython().kernel._show_mpl_backend_errors()")
631        self.shellwidget.sig_prompt_ready.disconnect(
632            self._show_mpl_backend_errors)
633