diff --git a/qtapputils/managers/capture.py b/qtapputils/managers/capture.py new file mode 100644 index 0000000..3455736 --- /dev/null +++ b/qtapputils/managers/capture.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © SARDES Project Contributors +# https://github.com/cgq-qgc/sardes +# +# This file is part of SARDES. +# Licensed under the terms of the GNU General Public License. +# ----------------------------------------------------------------------------- + +# ---- Standard library imports +import sys +import traceback + +# ---- Third party imports +from qtpy.QtCore import QObject, Signal +from qtpy.QtWidgets import QApplication + + +class ExceptHook(QObject): + """ + A Qt object to catch exceptions and emit a formatted string of the error. + """ + sig_except_caught = Signal(str) + + def excepthook(self, exc_type, exc_value, exc_traceback): + """Handle uncaught exceptions.""" + sys.__excepthook__(exc_type, exc_value, exc_traceback) + if not issubclass(exc_type, SystemExit): + log_msg = ''.join(traceback.format_exception( + exc_type, exc_value, exc_traceback)) + self.sig_except_caught.emit(log_msg) + + +class StandardStreamEmitter(QObject): + """ + A Qt object to intercept and emit the input and output of the + Python interpreter. + + https://docs.python.org/3/library/sys.html#sys.stdout + https://docs.python.org/3/library/sys.html#sys.stderr + """ + sig_new_text = Signal(str) + + def write(self, text): + try: + sys.__stdout__.write(text) + except Exception: + pass + self.sig_new_text.emit(str(text)) + + +class SysCaptureManager(QObject): + """ + A manager to capture and manage Python's standard input and output + streams, logging and internal errors reporting. + + Important Note: + the system capture manager should NOT be started when testing + under pytest because this will cause problems with the way + pytest is already capturing standard system messages and + raised exceptions. + + See pytest/src/_pytest/capture.py + """ + + def __init__(self, start_capture=False): + super().__init__() + self._stdstream_stack = '' + self._stdstream_consoles = [] + self._is_capturing = False + + self.except_dialog = None + + # Setup the Except hook. + self.except_hook = ExceptHook() + self.except_hook.sig_except_caught.connect(self._handle_except) + + # Setup the standard stream emitter. + self.stdout_emitter = StandardStreamEmitter() + self.stdout_emitter.sig_new_text.connect(self.__handle_stdout) + + self.stderr_emitter = StandardStreamEmitter() + self.stderr_emitter.sig_new_text.connect(self.handle_stderr) + + if start_capture: + self.start_capture() + + def start_capture(self): + """ + Start capturing Python interpreter standard messages and unhandled + raised exceptions. + + Important Note: + the system capture manager should NOT be started when testing + under pytest because this will cause problems with the way + pytest is already capturing standard system messages and + raised exceptions. + + See pytest/src/_pytest/capture.py + """ + self._is_capturing = True + self.__orig_except_hook = sys.excepthook + self.__orig_stdout = sys.stdout + self.__orig_stderr = sys.stderr + + sys.excepthook = self.except_hook.excepthook + sys.stdout = self.stdout_emitter + sys.stderr = self.stderr_emitter + + def stop_capture(self): + """ + Stop capturing Python interpreter standard messages and unhandled + raised exceptions. + """ + if self._is_capturing: + self._is_capturing = False + sys.excepthook = self.__orig_except_hook + sys.stdout = self.__orig_stdout + sys.stderr = self.__orig_stderr + + def register_stdstream_console(self, console): + """ + Register the specified console to this system capture manager. + """ + self._stdstream_consoles.append(console) + console.write(self._stdstream_stack) + + def register_except_dialog(self, except_dialog): + """ + Register the system except dialog. + """ + self.except_dialog = except_dialog + + def handle_stderr(self, text): + """ + Handle Python interpreter standard errors. + """ + self._stdstream_stack += text + for console in self._stdstream_consoles: + console.write(text) + + def __handle_stdout(self, text): + """ + Handle Python interpreter standard output. + """ + self._stdstream_stack += text + for console in self._stdstream_consoles: + console.write(text) + + def _handle_except(self, log_msg): + """ + Handle raised exceptions that have not been handled properly + internally and need to be reported for bug fixing. + """ + QApplication.restoreOverrideCursor() + if self.except_dialog is not None: + self.except_dialog.show_error( + log_msg, self._stdstream_stack + ) diff --git a/qtapputils/widgets/console.py b/qtapputils/widgets/console.py new file mode 100644 index 0000000..2735812 --- /dev/null +++ b/qtapputils/widgets/console.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © SARDES Project Contributors +# https://github.com/cgq-qgc/sardes +# +# This file is part of SARDES. +# Licensed under the terms of the GNU General Public License. +# ----------------------------------------------------------------------------- + +# ---- Standard library imports +import sys +import os.path as osp +import datetime + +# ---- Third party imports +from qtpy.QtCore import Qt +from qtpy.QtGui import QTextCursor, QIcon +from qtpy.QtWidgets import ( + QApplication, QDialog, QDialogButtonBox, QGridLayout, QPushButton, + QTextEdit, QWidget) + +# ---- Local imports +from qtapputils.managers import SaveFileManager + + +class StandardStreamConsole(QTextEdit): + """ + A Qt text edit to hold and show the standard input and output of the + Python interpreter. + """ + + def __init__(self): + super().__init__() + self.setReadOnly(True) + + def write(self, text): + self.moveCursor(QTextCursor.End) + self.insertPlainText(text) + + +class SystemMessageDialog(QDialog): + """ + A dialog to show and manage the standard input and ouput + of the Python interpreter. + """ + + def __init__(self, title: str, icon: QIcon = None, parent: QWidget = None): + super().__init__(parent) + self.setWindowFlags( + self.windowFlags() & + ~Qt.WindowContextHelpButtonHint | + Qt.WindowMinMaxButtonsHint) + if icon is not None: + self.setWindowIcon(icon) + self.setWindowTitle(title) + self.setMinimumSize(700, 500) + + def _save_file(filename, content): + with open(filename, 'w') as txtfile: + txtfile.write(content) + + self.savefile_manager = SaveFileManager( + namefilters={'.txt': "Text File (*.txt)"}, + onsave=_save_file, + parent=self + ) + self.std_console = StandardStreamConsole() + + # Setup the dialog button box. + self.saveas_btn = QPushButton('Save As') + self.saveas_btn.setDefault(False) + self.saveas_btn.clicked.connect(lambda checked: self.save_as()) + + self.close_btn = QPushButton('Close') + self.close_btn.setDefault(True) + self.close_btn.clicked.connect(self.close) + + self.copy_btn = QPushButton('Copy') + self.copy_btn.setDefault(False) + self.copy_btn.clicked.connect(self.copy_to_clipboard) + + button_box = QDialogButtonBox() + button_box.addButton(self.copy_btn, button_box.ActionRole) + button_box.addButton(self.saveas_btn, button_box.ActionRole) + button_box.addButton(self.close_btn, button_box.AcceptRole) + + # self.setCentralWidget(self.std_console) + layout = QGridLayout(self) + layout.addWidget(self.std_console, 0, 0) + layout.addWidget(button_box, 1, 0) + + def write(self, text): + self.std_console.write(text) + + def plain_text(self): + """ + Return the content of the console as plain text. + """ + return self.std_console.toPlainText() + + def save_as(self): + """ + Save the content of the console to a text file. + """ + now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + filename = osp.join( + osp.expanduser('~'), + 'HydrogeolabLog_{}.txt'.format(now) + ) + filename = self.savefile_manager.save_file_as( + filename, self.plain_text()) + + def copy_to_clipboard(self): + """ + Copy the content of the console on the clipboard. + """ + QApplication.clipboard().clear() + QApplication.clipboard().setText(self.plain_text()) + + def show(self): + """ + Override Qt method. + """ + if self.windowState() == Qt.WindowMinimized: + self.setWindowState(Qt.WindowNoState) + super().show() + self.activateWindow() + self.raise_() + + +if __name__ == '__main__': + from qtapputils.qthelpers import create_application + app = create_application() + + console = SystemMessageDialog() + console.show() + + sys.exit(app.exec_()) diff --git a/qtapputils/widgets/exceptions.py b/qtapputils/widgets/exceptions.py new file mode 100644 index 0000000..a78bf6a --- /dev/null +++ b/qtapputils/widgets/exceptions.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © SARDES Project Contributors +# https://github.com/cgq-qgc/sardes +# +# This file is part of SARDES. +# Licensed under the terms of the GNU General Public License. +# ----------------------------------------------------------------------------- +from __future__ import annotations +from typing import TYPE_CHECKING, Callable + +# ---- Standard library imports +import os +import os.path as osp +import sys +import datetime +import tempfile + + +# ---- Third party imports +from qtapputils.icons import get_standard_icon, get_standard_iconsize +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import ( + QApplication, QDialog, QDialogButtonBox, QGridLayout, QLabel, QPushButton, + QTextEdit, QWidget) + + +# ---- Local imports +from hydrogeolab.config.main import TEMP_DIR + + +class ExceptDialog(QDialog): + """ + A dialog to report internal errors encountered by the application during + execution. + """ + + def __init__(self, appname: str, appver: str, system_info: str = None, + icon: QIcon = None, issue_tracker: str = None, + issue_email: str = None, parent: QWidget = None): + super().__init__(parent) + self.setWindowTitle(f"{appname} Internal Error") + self.setWindowFlags( + self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + if icon is not None: + self.setWindowIcon(icon) + + self.log_msg = None + self.detailed_log = None + + self.appname = appname + self.appver = appver + self.system_info = system_info + + self.logmsg_textedit = QTextEdit() + self.logmsg_textedit.setReadOnly(True) + self.logmsg_textedit.setMinimumWidth(400) + self.logmsg_textedit.setLineWrapMode(self.logmsg_textedit.NoWrap) + + icon = get_standard_icon('SP_MessageBoxCritical') + iconsize = get_standard_iconsize('messagebox') + info_icon = QLabel() + info_icon.setScaledContents(False) + info_icon.setPixmap(icon.pixmap(iconsize)) + + # Setup dialog buttons. + self.ok_btn = QPushButton('OK') + self.ok_btn.setDefault(True) + self.ok_btn.clicked.connect(self.close) + + self.copy_btn = QPushButton('Copy') + self.copy_btn.setDefault(False) + self.copy_btn.clicked.connect(self.copy) + + button_box = QDialogButtonBox() + button_box.addButton(self.copy_btn, button_box.AcceptRole) + button_box.addButton(self.ok_btn, button_box.ActionRole) + + # Setup the dialog button box. + self.showlog_btn = QPushButton('Detailed Log') + self.showlog_btn.setDefault(False) + self.showlog_btn.clicked.connect(self.show_detailed_log) + button_box.addButton(self.showlog_btn, button_box.ResetRole) + + # Setup dialog main message. + message = ( + '{namever} has encountered an internal problem.' + '

We are sorry, but {appname} encountered an internal error ' + 'that might preventing it from running correctly. You might want ' + 'to save your work and restart {appname} if possible.

' + ).format( + namever=f"{appname} {appver}", + appname=appname) + if issue_tracker is not None: + message += ( + '

Please report this error by copying the information below ' + 'in our issues tracker and by ' + 'providing a step-by-step description of what led to the ' + 'problem.

' + ).format(issue_tracker=issue_tracker) + elif issue_email is not None: + message += ( + '

Please report this error by sending the information below ' + 'to {issue_email} and by ' + 'providing a step-by-step description of what led to the ' + 'problem.

' + ).format(issue_email=issue_email) + if any([issue_tracker, issue_email]): + if self.detailed_log is not None and len(self.detailed_log): + message += ( + '

If possible, please also attach to your report the ' + 'detailed log file accessible by clicking on the ' + 'Detailed Log button.

' + ) + msg_labl = QLabel(message) + msg_labl.setWordWrap(True) + msg_labl.setOpenExternalLinks(True) + + # Setup layout. + left_side_layout = QGridLayout() + left_side_layout.setContentsMargins(0, 0, 10, 0) + left_side_layout.addWidget(info_icon) + left_side_layout.setRowStretch(1, 1) + + right_side_layout = QGridLayout() + right_side_layout.setContentsMargins(0, 0, 0, 0) + right_side_layout.addWidget(msg_labl) + right_side_layout.addWidget(self.logmsg_textedit) + + main_layout = QGridLayout(self) + main_layout.addLayout(left_side_layout, 0, 0) + main_layout.addLayout(right_side_layout, 0, 1) + main_layout.addWidget(button_box, 1, 0, 1, 2) + + def set_log_message(self, log_msg): + """ + Set the log message related to the encountered error. + """ + self.logmsg_textedit.setText(self._render_error_infotext(log_msg)) + + def get_error_infotext(self): + """ + Return the text containing the information relevant to the + encountered error that can be copy-pasted directly + in an issue on GitHub. + """ + return self.logmsg_textedit.toPlainText() + + def _render_error_infotext(self, log_msg): + """ + Render the information relevant to the encountered error in a format + that can be copy-pasted directly in an issue on GitHub. + """ + message = f"{self.appname} {self.appver}\n\n" + if self.system_info is not None: + message += f"{self.system_info}\n\n" + message += log_msg + + return message + + def show_error(self, log_msg: str = None, detailed_log=None): + self.log_msg = log_msg + self.detailed_log = detailed_log + self.log_datetime = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + + self.showlog_btn.setVisible( + detailed_log is not None and len(detailed_log)) + self.set_log_message(log_msg) + + self.exec_() + + def show_detailed_log(self): + """ + Open the detailed log file in an external application that is + chosen by the OS. + """ + name = '{}Log_{}.txt'.format(self.appname, self.log_datetime) + temp_path = tempfile.mkdtemp(dir=TEMP_DIR) + temp_filename = osp.join(temp_path, name) + with open(temp_filename, 'w') as txtfile: + txtfile.write(self.detailed_log) + os.startfile(temp_filename) + + def copy(self): + """ + Copy the issue on the clipboard. + """ + QApplication.clipboard().clear() + QApplication.clipboard().setText(self.get_error_infotext()) + + +if __name__ == '__main__': + app = QApplication(sys.argv) + dialog = ExceptDialog( + 'MyApp', appver='0.1.3', issue_email="info@geostack.ca" + ) + dialog.show_error("Some Traceback\n") + sys.exit(app.exec_())