Skip to content

Commit 2894cd4

Browse files
Merge pull request #25 from jnsebgosselin/add_error_and_sysmsg_manager
PR: Add errors and system message manager
2 parents 82a11aa + 840ac62 commit 2894cd4

File tree

3 files changed

+496
-0
lines changed

3 files changed

+496
-0
lines changed

qtapputils/managers/capture.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# -*- coding: utf-8 -*-
2+
# -----------------------------------------------------------------------------
3+
# Copyright © SARDES Project Contributors
4+
# https://github.com/cgq-qgc/sardes
5+
#
6+
# This file is part of SARDES.
7+
# Licensed under the terms of the GNU General Public License.
8+
# -----------------------------------------------------------------------------
9+
10+
# ---- Standard library imports
11+
import sys
12+
import traceback
13+
14+
# ---- Third party imports
15+
from qtpy.QtCore import QObject, Signal
16+
from qtpy.QtWidgets import QApplication
17+
18+
19+
class ExceptHook(QObject):
20+
"""
21+
A Qt object to catch exceptions and emit a formatted string of the error.
22+
"""
23+
sig_except_caught = Signal(str)
24+
25+
def excepthook(self, exc_type, exc_value, exc_traceback):
26+
"""Handle uncaught exceptions."""
27+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
28+
if not issubclass(exc_type, SystemExit):
29+
log_msg = ''.join(traceback.format_exception(
30+
exc_type, exc_value, exc_traceback))
31+
self.sig_except_caught.emit(log_msg)
32+
33+
34+
class StandardStreamEmitter(QObject):
35+
"""
36+
A Qt object to intercept and emit the input and output of the
37+
Python interpreter.
38+
39+
https://docs.python.org/3/library/sys.html#sys.stdout
40+
https://docs.python.org/3/library/sys.html#sys.stderr
41+
"""
42+
sig_new_text = Signal(str)
43+
44+
def write(self, text):
45+
try:
46+
sys.__stdout__.write(text)
47+
except Exception:
48+
pass
49+
self.sig_new_text.emit(str(text))
50+
51+
52+
class SysCaptureManager(QObject):
53+
"""
54+
A manager to capture and manage Python's standard input and output
55+
streams, logging and internal errors reporting.
56+
57+
Important Note:
58+
the system capture manager should NOT be started when testing
59+
under pytest because this will cause problems with the way
60+
pytest is already capturing standard system messages and
61+
raised exceptions.
62+
63+
See pytest/src/_pytest/capture.py
64+
"""
65+
66+
def __init__(self, start_capture=False):
67+
super().__init__()
68+
self._stdstream_stack = ''
69+
self._stdstream_consoles = []
70+
self._is_capturing = False
71+
72+
self.except_dialog = None
73+
74+
# Setup the Except hook.
75+
self.except_hook = ExceptHook()
76+
self.except_hook.sig_except_caught.connect(self._handle_except)
77+
78+
# Setup the standard stream emitter.
79+
self.stdout_emitter = StandardStreamEmitter()
80+
self.stdout_emitter.sig_new_text.connect(self.__handle_stdout)
81+
82+
self.stderr_emitter = StandardStreamEmitter()
83+
self.stderr_emitter.sig_new_text.connect(self.handle_stderr)
84+
85+
if start_capture:
86+
self.start_capture()
87+
88+
def start_capture(self):
89+
"""
90+
Start capturing Python interpreter standard messages and unhandled
91+
raised exceptions.
92+
93+
Important Note:
94+
the system capture manager should NOT be started when testing
95+
under pytest because this will cause problems with the way
96+
pytest is already capturing standard system messages and
97+
raised exceptions.
98+
99+
See pytest/src/_pytest/capture.py
100+
"""
101+
self._is_capturing = True
102+
self.__orig_except_hook = sys.excepthook
103+
self.__orig_stdout = sys.stdout
104+
self.__orig_stderr = sys.stderr
105+
106+
sys.excepthook = self.except_hook.excepthook
107+
sys.stdout = self.stdout_emitter
108+
sys.stderr = self.stderr_emitter
109+
110+
def stop_capture(self):
111+
"""
112+
Stop capturing Python interpreter standard messages and unhandled
113+
raised exceptions.
114+
"""
115+
if self._is_capturing:
116+
self._is_capturing = False
117+
sys.excepthook = self.__orig_except_hook
118+
sys.stdout = self.__orig_stdout
119+
sys.stderr = self.__orig_stderr
120+
121+
def register_stdstream_console(self, console):
122+
"""
123+
Register the specified console to this system capture manager.
124+
"""
125+
self._stdstream_consoles.append(console)
126+
console.write(self._stdstream_stack)
127+
128+
def register_except_dialog(self, except_dialog):
129+
"""
130+
Register the system except dialog.
131+
"""
132+
self.except_dialog = except_dialog
133+
134+
def handle_stderr(self, text):
135+
"""
136+
Handle Python interpreter standard errors.
137+
"""
138+
self._stdstream_stack += text
139+
for console in self._stdstream_consoles:
140+
console.write(text)
141+
142+
def __handle_stdout(self, text):
143+
"""
144+
Handle Python interpreter standard output.
145+
"""
146+
self._stdstream_stack += text
147+
for console in self._stdstream_consoles:
148+
console.write(text)
149+
150+
def _handle_except(self, log_msg):
151+
"""
152+
Handle raised exceptions that have not been handled properly
153+
internally and need to be reported for bug fixing.
154+
"""
155+
QApplication.restoreOverrideCursor()
156+
if self.except_dialog is not None:
157+
self.except_dialog.show_error(
158+
log_msg, self._stdstream_stack
159+
)

qtapputils/widgets/console.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# -*- coding: utf-8 -*-
2+
# -----------------------------------------------------------------------------
3+
# Copyright © SARDES Project Contributors
4+
# https://github.com/cgq-qgc/sardes
5+
#
6+
# This file is part of SARDES.
7+
# Licensed under the terms of the GNU General Public License.
8+
# -----------------------------------------------------------------------------
9+
10+
# ---- Standard library imports
11+
import sys
12+
import os.path as osp
13+
import datetime
14+
15+
# ---- Third party imports
16+
from qtpy.QtCore import Qt
17+
from qtpy.QtGui import QTextCursor, QIcon
18+
from qtpy.QtWidgets import (
19+
QApplication, QDialog, QDialogButtonBox, QGridLayout, QPushButton,
20+
QTextEdit, QWidget)
21+
22+
# ---- Local imports
23+
from qtapputils.managers import SaveFileManager
24+
25+
26+
class StandardStreamConsole(QTextEdit):
27+
"""
28+
A Qt text edit to hold and show the standard input and output of the
29+
Python interpreter.
30+
"""
31+
32+
def __init__(self):
33+
super().__init__()
34+
self.setReadOnly(True)
35+
36+
def write(self, text):
37+
self.moveCursor(QTextCursor.End)
38+
self.insertPlainText(text)
39+
40+
41+
class SystemMessageDialog(QDialog):
42+
"""
43+
A dialog to show and manage the standard input and ouput
44+
of the Python interpreter.
45+
"""
46+
47+
def __init__(self, title: str, icon: QIcon = None, parent: QWidget = None):
48+
super().__init__(parent)
49+
self.setWindowFlags(
50+
self.windowFlags() &
51+
~Qt.WindowContextHelpButtonHint |
52+
Qt.WindowMinMaxButtonsHint)
53+
if icon is not None:
54+
self.setWindowIcon(icon)
55+
self.setWindowTitle(title)
56+
self.setMinimumSize(700, 500)
57+
58+
def _save_file(filename, content):
59+
with open(filename, 'w') as txtfile:
60+
txtfile.write(content)
61+
62+
self.savefile_manager = SaveFileManager(
63+
namefilters={'.txt': "Text File (*.txt)"},
64+
onsave=_save_file,
65+
parent=self
66+
)
67+
self.std_console = StandardStreamConsole()
68+
69+
# Setup the dialog button box.
70+
self.saveas_btn = QPushButton('Save As')
71+
self.saveas_btn.setDefault(False)
72+
self.saveas_btn.clicked.connect(lambda checked: self.save_as())
73+
74+
self.close_btn = QPushButton('Close')
75+
self.close_btn.setDefault(True)
76+
self.close_btn.clicked.connect(self.close)
77+
78+
self.copy_btn = QPushButton('Copy')
79+
self.copy_btn.setDefault(False)
80+
self.copy_btn.clicked.connect(self.copy_to_clipboard)
81+
82+
button_box = QDialogButtonBox()
83+
button_box.addButton(self.copy_btn, button_box.ActionRole)
84+
button_box.addButton(self.saveas_btn, button_box.ActionRole)
85+
button_box.addButton(self.close_btn, button_box.AcceptRole)
86+
87+
# self.setCentralWidget(self.std_console)
88+
layout = QGridLayout(self)
89+
layout.addWidget(self.std_console, 0, 0)
90+
layout.addWidget(button_box, 1, 0)
91+
92+
def write(self, text):
93+
self.std_console.write(text)
94+
95+
def plain_text(self):
96+
"""
97+
Return the content of the console as plain text.
98+
"""
99+
return self.std_console.toPlainText()
100+
101+
def save_as(self):
102+
"""
103+
Save the content of the console to a text file.
104+
"""
105+
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
106+
filename = osp.join(
107+
osp.expanduser('~'),
108+
'HydrogeolabLog_{}.txt'.format(now)
109+
)
110+
filename = self.savefile_manager.save_file_as(
111+
filename, self.plain_text())
112+
113+
def copy_to_clipboard(self):
114+
"""
115+
Copy the content of the console on the clipboard.
116+
"""
117+
QApplication.clipboard().clear()
118+
QApplication.clipboard().setText(self.plain_text())
119+
120+
def show(self):
121+
"""
122+
Override Qt method.
123+
"""
124+
if self.windowState() == Qt.WindowMinimized:
125+
self.setWindowState(Qt.WindowNoState)
126+
super().show()
127+
self.activateWindow()
128+
self.raise_()
129+
130+
131+
if __name__ == '__main__':
132+
from qtapputils.qthelpers import create_application
133+
app = create_application()
134+
135+
console = SystemMessageDialog()
136+
console.show()
137+
138+
sys.exit(app.exec_())

0 commit comments

Comments
 (0)