# -*- coding: utf-8 -*- # ---------------------------------------------------------------------------- # Copyright © Spyder Project Contributors # # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ---------------------------------------------------------------------------- """Report Error Dialog.""" # Standard library imports import sys from urllib.parse import quote # Third party imports from qtpy.QtCore import Qt, QUrl, QUrlQuery, Signal from qtpy.QtGui import QDesktopServices from qtpy.QtWidgets import (QApplication, QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout) # Local imports from spyder import (__project_url__, __trouble_url__, dependencies, get_versions) from spyder.config.base import _, running_under_pytest from spyder.config.gui import get_font from spyder.plugins.console.widgets.console import ConsoleBaseWidget from spyder.utils.icon_manager import ima from spyder.utils.qthelpers import restore_keyevent from spyder.widgets.github.backend import GithubBackend from spyder.widgets.mixins import BaseEditMixin, TracebackLinksMixin from spyder.widgets.simplecodeeditor import SimpleCodeEditor # Minimum number of characters to introduce in the title and # description fields before being able to send the report to # Github. TITLE_MIN_CHARS = 15 DESC_MIN_CHARS = 50 class DescriptionWidget(SimpleCodeEditor): """Widget to enter error description.""" def __init__(self, parent=None): super().__init__(parent) # Editor options self.setup_editor( language='md', font=get_font(), wrap=True, linenumbers=False, highlight_current_line=False, ) # Header self.header = ( "### What steps will reproduce the problem?\n\n" "\n\n") self.set_text(self.header) self.move_cursor(len(self.header)) self.header_end_pos = self.get_position('eof') def remove_text(self): """Remove text.""" self.truncate_selection(self.header_end_pos) self.remove_selected_text() def cut(self): """Cut text""" self.truncate_selection(self.header_end_pos) if self.has_selected_text(): super().cut() def keyPressEvent(self, event): """Reimplemented Qt Method to avoid removing the header.""" event, text, key, ctrl, shift = restore_keyevent(event) cursor_position = self.get_position('cursor') if cursor_position < self.header_end_pos: self.restrict_cursor_position(self.header_end_pos, 'eof') elif key == Qt.Key_Backspace: if self.has_selected_text(): self.remove_text() elif self.header_end_pos == cursor_position: return else: self.stdkey_backspace() elif key == Qt.Key_X and ctrl: self.cut() else: super().keyPressEvent(event) def delete(self): """Reimplemented to avoid removing the header.""" cursor_position = self.get_position('cursor') if cursor_position < self.header_end_pos: self.restrict_cursor_position(self.header_end_pos, 'eof') elif self.has_selected_text(): self.remove_text() else: self.stdkey_clear() def contextMenuEvent(self, event): """Reimplemented Qt Method to not show the context menu.""" pass class ShowErrorWidget(TracebackLinksMixin, ConsoleBaseWidget, BaseEditMixin): """Widget to show errors as they appear in the Internal console.""" QT_CLASS = QPlainTextEdit sig_go_to_error_requested = Signal(str) def __init__(self, parent=None): ConsoleBaseWidget.__init__(self, parent) BaseEditMixin.__init__(self) TracebackLinksMixin.__init__(self) self.setReadOnly(True) class SpyderErrorDialog(QDialog): """Custom error dialog for error reporting.""" def __init__(self, parent=None, is_report=False): QDialog.__init__(self, parent) self.is_report = is_report self.setWindowTitle(_("Issue reporter")) self._github_org = 'spyder-ide' self._github_repo = 'spyder' # To save the traceback sent to the internal console self.error_traceback = "" # Dialog main label if self.is_report: title = _("Please fill the following information") else: title = _("Spyder has encountered an internal problem!") self.main_label = QLabel( _("

{title}

" "Before reporting this problem, please consult our " "comprehensive " "Troubleshooting Guide " "which should help solve most issues, and search for " "known bugs " "matching your error message or problem description for a " "quicker solution." ).format(title=title, trouble_url=__trouble_url__, project_url=__project_url__)) self.main_label.setOpenExternalLinks(True) self.main_label.setWordWrap(True) self.main_label.setAlignment(Qt.AlignJustify) self.main_label.setStyleSheet('font-size: 12px;') # Issue title self.title = QLineEdit() self.title.textChanged.connect(self._contents_changed) self.title_chars_label = QLabel(_("{} more characters " "to go...").format(TITLE_MIN_CHARS)) form_layout = QFormLayout() form_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) red_asterisk = '*' title_label = QLabel(_("Title: {}").format(red_asterisk)) form_layout.setWidget(0, QFormLayout.LabelRole, title_label) form_layout.setWidget(0, QFormLayout.FieldRole, self.title) # Description steps_header = QLabel( _("Steps to reproduce: {}").format(red_asterisk)) self.steps_text = QLabel( _("Please enter a detailed step-by-step " "description (in English) of what led up to " "the problem below. Issue reports without a " "clear way to reproduce them will be closed.") ) self.steps_text.setWordWrap(True) self.steps_text.setAlignment(Qt.AlignJustify) self.steps_text.setStyleSheet('font-size: 12px;') # Field to input the description of the problem self.input_description = DescriptionWidget(self) # Only allow to submit to Github if we have a long enough description self.input_description.textChanged.connect(self._contents_changed) # Widget to show errors self.details = ShowErrorWidget(self) self.details.set_pythonshell_font(get_font()) self.details.hide() self.description_minimum_length = DESC_MIN_CHARS self.require_minimum_length = True # Label to show missing chars self.initial_chars = len(self.input_description.toPlainText()) self.desc_chars_label = QLabel(_("{} more characters " "to go...").format( self.description_minimum_length)) # Checkbox to dismiss future errors self.dismiss_box = QCheckBox(_("Hide all future errors during this " "session")) if self.is_report: self.dismiss_box.hide() # Dialog buttons gh_icon = ima.icon('github') self.submit_btn = QPushButton(gh_icon, _('Submit to Github')) self.submit_btn.setEnabled(False) self.submit_btn.clicked.connect(self._submit_to_github) self.details_btn = QPushButton(_('Show details')) self.details_btn.clicked.connect(self._show_details) if self.is_report: self.details_btn.hide() self.close_btn = QPushButton(_('Close')) if self.is_report: self.close_btn.clicked.connect(self.reject) # Buttons layout buttons_layout = QHBoxLayout() buttons_layout.addWidget(self.submit_btn) buttons_layout.addWidget(self.details_btn) buttons_layout.addWidget(self.close_btn) # Main layout layout = QVBoxLayout() layout.addWidget(self.main_label) layout.addSpacing(20) layout.addLayout(form_layout) layout.addWidget(self.title_chars_label) layout.addSpacing(12) layout.addWidget(steps_header) layout.addSpacing(-1) layout.addWidget(self.steps_text) layout.addSpacing(1) layout.addWidget(self.input_description) layout.addWidget(self.details) layout.addWidget(self.desc_chars_label) layout.addSpacing(15) layout.addWidget(self.dismiss_box) layout.addSpacing(15) layout.addLayout(buttons_layout) layout.setContentsMargins(25, 20, 25, 10) self.setLayout(layout) self.resize(570, 600) self.title.setFocus() # Set Tab key focus order self.setTabOrder(self.title, self.input_description) @staticmethod def render_issue(description='', traceback=''): """ Render issue content. Parameters ---------- description: str Description to include in issue message. traceback: str Traceback text. """ # Get component versions versions = get_versions() # Get git revision for development version revision = '' if versions['revision']: revision = versions['revision'] # Make a description header in case no description is supplied if not description: description = "### What steps reproduce the problem?" # Make error section from traceback and add appropriate reminder header if traceback: error_section = ("### Traceback\n" "```python-traceback\n" "{}\n" "```".format(traceback)) else: error_section = '' issue_template = """\ ## Description {description} {error_section} ## Versions * Spyder version: {spyder_version} {commit} * Python version: {python_version} * Qt version: {qt_version} * {qt_api_name} version: {qt_api_version} * Operating System: {os_name} {os_version} ### Dependencies ``` {dependencies} ``` """.format(description=description, error_section=error_section, spyder_version=versions['spyder'], commit=revision, python_version=versions['python'], qt_version=versions['qt'], qt_api_name=versions['qt_api'], qt_api_version=versions['qt_api_ver'], os_name=versions['system'], os_version=versions['release'], dependencies=dependencies.status()) return issue_template @staticmethod def open_web_report(body, title=None): """ Open a new issue on Github with prefilled information. Parameters ---------- body: str The body content of the report. title: str or None, optional The title of the report. Default is None. """ url = QUrl(__project_url__ + '/issues/new') query = QUrlQuery() query.addQueryItem("body", quote(body)) if title: query.addQueryItem("title", quote(title)) url.setQuery(query) QDesktopServices.openUrl(url) def set_require_minimum_length(self, state): """Remove the requirement for minimum length.""" self.require_minimum_length = state if state: self._contents_changed() else: self.desc_chars_label.setText('') def set_github_repo_org(self, repo_fullname): """Set the report Github organization and repository.""" org, repo = repo_fullname.split('/') self._github_org = org self._github_repo = repo def _submit_to_github(self): """Action to take when pressing the submit button.""" # Getting description and traceback title = self.title.text() description = self.input_description.toPlainText() traceback = self.error_traceback[:-1] # Remove last EOL # Render issue if traceback: issue_text = self.render_issue(description=description, traceback=traceback) else: issue_text = description try: if running_under_pytest(): org = 'ccordoba12' else: org = self._github_org repo = self._github_repo github_backend = GithubBackend(org, repo, parent_widget=self) github_report = github_backend.send_report(title, issue_text) if github_report: self.close() except Exception: ret = QMessageBox.question( self, _('Error'), _("An error occurred while trying to send the issue to " "Github automatically. Would you like to open it " "manually?

" "If so, please make sure to paste your clipboard " "into the issue report box that will appear in a new " "browser tab before clicking Submit on that " "page."), ) if ret in [QMessageBox.Yes, QMessageBox.Ok]: QApplication.clipboard().setText(issue_text) issue_body = ( " \n\n") self.open_web_report(body=issue_body, title=title) def append_traceback(self, text): """Append text to the traceback, to be displayed in details.""" self.error_traceback += text def _show_details(self): """Show traceback on its own dialog""" if self.details.isVisible(): self.details.hide() self.details_btn.setText(_('Show details')) else: self.resize(570, 700) self.details.document().setPlainText('') self.details.append_text_to_shell(self.error_traceback, error=True, prompt=False) self.details.show() self.details_btn.setText(_('Hide details')) def _contents_changed(self): """Activate submit_btn.""" if not self.require_minimum_length: return desc_chars = (len(self.input_description.toPlainText()) - self.initial_chars) if desc_chars < self.description_minimum_length: self.desc_chars_label.setText( u"{} {}".format(self.description_minimum_length - desc_chars, _("more characters to go..."))) else: self.desc_chars_label.setText(_("Description complete; thanks!")) title_chars = len(self.title.text()) if title_chars < TITLE_MIN_CHARS: self.title_chars_label.setText( u"{} {}".format(TITLE_MIN_CHARS - title_chars, _("more characters to go..."))) else: self.title_chars_label.setText(_("Title complete; thanks!")) submission_enabled = (desc_chars >= self.description_minimum_length and title_chars >= TITLE_MIN_CHARS) self.submit_btn.setEnabled(submission_enabled) def set_title(self, title): """Set the title for the report.""" self.title.setText(title) def set_description(self, description): """Set the description for the report.""" self.input_description.setPlainText(description) def set_color_scheme(self, color_scheme): """Set the color scheme for the description input.""" self.input_description.set_color_scheme(color_scheme) def test(): from spyder.utils.qthelpers import qapplication app = qapplication() dlg = SpyderErrorDialog() dlg.show() sys.exit(dlg.exec_()) if __name__ == "__main__": test()