Improve config error handling

- Errors are now combined if possible
- Rich text output in message boxes
- ConfigContainer errors are collected properly
This commit is contained in:
Florian Bruhin 2017-09-14 22:47:06 +02:00
parent 490de32b49
commit b8fb88f4c2
7 changed files with 92 additions and 78 deletions

View File

@ -408,7 +408,7 @@ def _init_modules(args, crash_handler):
objreg.register('readline-bridge', readline_bridge) objreg.register('readline-bridge', readline_bridge)
log.init.debug("Initializing config...") log.init.debug("Initializing config...")
config.init(args, qApp) config.init(qApp)
save_manager.init_autosave() save_manager.init_autosave()
log.init.debug("Initializing sql...") log.init.debug("Initializing sql...")

View File

@ -24,10 +24,11 @@ import contextlib
import functools import functools
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl
from PyQt5.QtWidgets import QApplication, QMessageBox
from qutebrowser.config import configdata, configexc, configtypes, configfiles from qutebrowser.config import configdata, configexc, configtypes, configfiles
from qutebrowser.utils import utils, objreg, message, log, usertypes, error from qutebrowser.utils import utils, objreg, message, log, usertypes
from qutebrowser.misc import objects from qutebrowser.misc import objects, msgbox
from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.completion.models import configmodel from qutebrowser.completion.models import configmodel
@ -35,6 +36,7 @@ from qutebrowser.completion.models import configmodel
val = None val = None
instance = None instance = None
key_instance = None key_instance = None
_errbox = None
# Keeping track of all change filters to validate them later. # Keeping track of all change filters to validate them later.
_change_filters = [] _change_filters = []
@ -500,27 +502,28 @@ class ConfigContainer:
Attributes: Attributes:
_config: The Config object. _config: The Config object.
_prefix: The __getattr__ chain leading up to this object. _prefix: The __getattr__ chain leading up to this object.
_confpy: If True, get values suitable for config.py and _configapi: If given, get values suitable for config.py and
do not raise exceptions. add errors to the given ConfigAPI object.
_errors: If confpy=True is given, a list of configexc.ConfigErrorDesc.
""" """
def __init__(self, config, confpy=False, prefix=''): def __init__(self, config, configapi=None, prefix=''):
self._config = config self._config = config
self._prefix = prefix self._prefix = prefix
self._confpy = confpy self._configapi = configapi
self._errors = []
def __repr__(self): def __repr__(self):
return utils.get_repr(self, constructor=True, config=self._config, return utils.get_repr(self, constructor=True, config=self._config,
confpy=self._confpy, prefix=self._prefix) configapi=self._configapi, prefix=self._prefix)
@contextlib.contextmanager @contextlib.contextmanager
def _handle_error(self, action, name): def _handle_error(self, action, name):
try: try:
yield yield
except configexc.Error as e: except configexc.Error as e:
self._errors.append(configexc.ConfigErrorDesc(action, name, e)) if self._configapi is None:
raise
text = "While {} '{}'".format(action, name)
self._configapi.errors.append(configexc.ConfigErrorDesc(text, e))
def __getattr__(self, attr): def __getattr__(self, attr):
"""Get an option or a new ConfigContainer with the added prefix. """Get an option or a new ConfigContainer with the added prefix.
@ -536,14 +539,17 @@ class ConfigContainer:
name = self._join(attr) name = self._join(attr)
if configdata.is_valid_prefix(name): if configdata.is_valid_prefix(name):
return ConfigContainer(config=self._config, confpy=self._confpy, return ConfigContainer(config=self._config,
configapi=self._configapi,
prefix=name) prefix=name)
with self._handle_error('getting', name): with self._handle_error('getting', name):
if self._confpy: if self._configapi is None:
return self._config.get_obj(name) # access from Python code
else:
return self._config.get(name) return self._config.get(name)
else:
# access from config.py
return self._config.get_obj(name)
def __setattr__(self, attr, value): def __setattr__(self, attr, value):
"""Set the given option in the config.""" """Set the given option in the config."""
@ -630,7 +636,7 @@ class StyleSheetObserver(QObject):
instance.changed.connect(self._update_stylesheet) instance.changed.connect(self._update_stylesheet)
def init(args, parent=None): def init(parent=None):
"""Initialize the config. """Initialize the config.
Args: Args:
@ -655,9 +661,13 @@ def init(args, parent=None):
try: try:
config_api = configfiles.read_config_py() config_api = configfiles.read_config_py()
except configexc.ConfigFileError as e: except configexc.ConfigFileErrors as e:
error.handle_fatal_exc(e, args=args, global _errbox
title="Error while loading config") _errbox = msgbox.msgbox(parent=None,
title="Error while reading config",
text=e.to_html(),
icon=QMessageBox.Warning,
plain_text=False)
config_api = None config_api = None
if getattr(config_api, 'load_autoconfig', True): if getattr(config_api, 'load_autoconfig', True):

View File

@ -20,7 +20,7 @@
"""Exceptions related to config parsing.""" """Exceptions related to config parsing."""
import traceback from qutebrowser.utils import jinja
class Error(Exception): class Error(Exception):
@ -75,49 +75,46 @@ class NoOptionError(Error):
self.option = option self.option = option
class ConfigFileError(Error):
"""Raised when there was an error while loading a config file."""
def __init__(self, basename, msg):
super().__init__("Failed to load {}: {}".format(basename, msg))
class ConfigFileUnhandledException(ConfigFileError):
"""Raised when there was an unhandled exception while loading config.py.
Needs to be raised from an exception handler.
"""
def __init__(self, basename):
super().__init__(basename, "Unhandled exception\n\n{}".format(
traceback.format_exc()))
class ConfigErrorDesc: class ConfigErrorDesc:
"""A description of an error happening while reading the config. """A description of an error happening while reading the config.
Attributes: Attributes:
_action: What action has been taken, e.g 'set' text: The text to show.
_name: The option which was set, or the key which was bound. exception: The exception which happened.
_exception: The exception which happened. traceback: The formatted traceback of the exception.
""" """
def __init__(self, action, name, exception): def __init__(self, text, exception, traceback=None):
self._action = action self.text = text
self._exception = exception self.exception = exception
self._name = name self.traceback = traceback
def __str__(self):
return "While {} {}: {}".format(
self._action, self._name, self._exception)
class ConfigFileErrors(ConfigFileError): class ConfigFileErrors(Error):
"""Raised when multiple errors occurred inside the config.""" """Raised when multiple errors occurred inside the config."""
def __init__(self, basename, errors): def __init__(self, basename, errors):
super().__init__(basename, "\n\n".join(str(err) for err in errors)) super().__init__("Errors occurred while reading {}".format(basename))
self.basename = basename
self.errors = errors
def to_html(self):
template = jinja.environment.from_string("""
Errors occurred while reading {{ basename }}:
<ul>
{% for error in errors %}
<li>
<b>{{ error.text }}</b>: {{ error.exception }}
{% if error.traceback != none %}
<pre>
""".rstrip() + "\n{{ error.traceback }}" + """
</pre>
{% endif %}
</li>
{% endfor %}
</ul>
""")
return template.render(basename=self.basename, errors=self.errors)

View File

@ -110,24 +110,24 @@ class ConfigAPI:
errors: Errors which occurred while setting options. errors: Errors which occurred while setting options.
""" """
def __init__(self, config, keyconfig, container): def __init__(self, config, keyconfig):
self._config = config self._config = config
self._keyconfig = keyconfig self._keyconfig = keyconfig
self.val = container
self.load_autoconfig = True self.load_autoconfig = True
self.errors = [] self.errors = []
self.val = None # Set when initialized
@contextlib.contextmanager @contextlib.contextmanager
def _handle_error(self, action, name): def _handle_error(self, action, name):
try: try:
yield yield
except configexc.Error as e: except configexc.Error as e:
self.errors.append(configexc.ConfigErrorDesc(action, name, e)) text = "While {} '{}'".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
def finalize(self): def finalize(self):
"""Needs to get called after reading config.py is done.""" """Needs to get called after reading config.py is done."""
self._config.update_mutables() self._config.update_mutables()
self.errors += self.val._errors # pylint: disable=protected-access
def get(self, name): def get(self, name):
with self._handle_error('getting', name): with self._handle_error('getting', name):
@ -155,32 +155,42 @@ def read_config_py(filename=None):
if not os.path.exists(filename): if not os.path.exists(filename):
return None return None
container = config.ConfigContainer(config.instance, confpy=True) api = ConfigAPI(config.instance, config.key_instance)
api = ConfigAPI(config.instance, config.key_instance, container) container = config.ConfigContainer(config.instance, configapi=api)
api.val = container
module = types.ModuleType('config') module = types.ModuleType('config')
module.config = api module.config = api
module.c = api.val module.c = container
module.__file__ = filename module.__file__ = filename
basename = os.path.basename(filename) basename = os.path.basename(filename)
try: try:
with open(filename, mode='rb') as f: with open(filename, mode='rb') as f:
source = f.read() source = f.read()
except OSError as e: except OSError as e:
raise configexc.ConfigFileError(basename, e.strerror) text = "Error while reading {}".format(basename)
desc = configexc.ConfigErrorDesc(text, e)
raise configexc.ConfigFileErrors(basename, [desc])
try: try:
code = compile(source, filename, 'exec') code = compile(source, filename, 'exec')
except ValueError as e: except ValueError as e:
# source contains NUL bytes # source contains NUL bytes
raise configexc.ConfigFileError(basename, str(e)) desc = configexc.ConfigErrorDesc("Error while compiling", e)
except SyntaxError: raise configexc.ConfigFileErrors(basename, [desc])
raise configexc.ConfigFileUnhandledException(basename) except SyntaxError as e:
desc = configexc.ConfigErrorDesc("Syntax Error", e,
traceback=traceback.format_exc())
raise configexc.ConfigFileErrors(basename, [desc])
try: try:
exec(code, module.__dict__) exec(code, module.__dict__)
except Exception: except Exception as e:
raise configexc.ConfigFileUnhandledException(basename) api.errors.append(configexc.ConfigErrorDesc(
"Unhandled exception",
exception=e, traceback=traceback.format_exc()))
api.finalize() api.finalize()
if api.errors: if api.errors:

View File

@ -41,6 +41,7 @@ def msgbox(parent, title, text, *, icon, buttons=QMessageBox.Ok,
A new QMessageBox. A new QMessageBox.
""" """
box = QMessageBox(parent) box = QMessageBox(parent)
box.setAttribute(Qt.WA_DeleteOnClose)
box.setIcon(icon) box.setIcon(icon)
box.setStandardButtons(buttons) box.setStandardButtons(buttons)
if on_finished is not None: if on_finished is not None:

View File

@ -36,8 +36,7 @@ def _get_name(exc):
return name return name
def handle_fatal_exc(exc, args, title, *, pre_text='', post_text='', def handle_fatal_exc(exc, args, title, *, pre_text='', post_text=''):
richtext=False):
"""Handle a fatal "expected" exception by displaying an error box. """Handle a fatal "expected" exception by displaying an error box.
If --no-err-windows is given as argument, the text is logged to the error If --no-err-windows is given as argument, the text is logged to the error
@ -49,7 +48,6 @@ def handle_fatal_exc(exc, args, title, *, pre_text='', post_text='',
title: The title to be used for the error message. title: The title to be used for the error message.
pre_text: The text to be displayed before the exception text. pre_text: The text to be displayed before the exception text.
post_text: The text to be displayed after the exception text. post_text: The text to be displayed after the exception text.
richtext: If given, interpret the given text as rich text.
""" """
if args.no_err_windows: if args.no_err_windows:
lines = [ lines = [
@ -69,6 +67,4 @@ def handle_fatal_exc(exc, args, title, *, pre_text='', post_text='',
if post_text: if post_text:
msg_text += '\n\n{}'.format(post_text) msg_text += '\n\n{}'.format(post_text)
msgbox = QMessageBox(QMessageBox.Critical, title, msg_text) msgbox = QMessageBox(QMessageBox.Critical, title, msg_text)
if richtext:
msgbox.setTextFormat(Qt.RichText)
msgbox.exec_() msgbox.exec_()

View File

@ -777,12 +777,12 @@ class TestContainer:
new_container = new_container.favicons new_container = new_container.favicons
assert new_container._prefix == 'tabs.favicons' assert new_container._prefix == 'tabs.favicons'
@pytest.mark.parametrize('confpy, expected', [ @pytest.mark.parametrize('configapi, expected', [
(True, 'rgb'), (object(), 'rgb'),
(False, QColor.Rgb), (None, QColor.Rgb),
]) ])
def test_getattr_option(self, container, confpy, expected): def test_getattr_option(self, container, configapi, expected):
container._confpy = confpy container._configapi = configapi
# Use an option with a to_py() so we can check the conversion. # Use an option with a to_py() so we can check the conversion.
assert container.colors.downloads.system.fg == expected assert container.colors.downloads.system.fg == expected
@ -877,7 +877,7 @@ def test_init(init_patch, fake_save_manager, config_tmpdir, load_autoconfig):
config_py_lines.append('config.load_autoconfig = False') config_py_lines.append('config.load_autoconfig = False')
config_py_file.write_text('\n'.join(config_py_lines), 'utf-8', ensure=True) config_py_file.write_text('\n'.join(config_py_lines), 'utf-8', ensure=True)
config.init(args=None) config.init()
objreg.get('config-commands') objreg.get('config-commands')
assert isinstance(config.instance, config.Config) assert isinstance(config.instance, config.Config)
@ -899,4 +899,4 @@ def test_init(init_patch, fake_save_manager, config_tmpdir, load_autoconfig):
def test_init_invalid_change_filter(init_patch): def test_init_invalid_change_filter(init_patch):
config.change_filter('foobar') config.change_filter('foobar')
with pytest.raises(configexc.NoOptionError): with pytest.raises(configexc.NoOptionError):
config.init(args=None) config.init()