qutebrowser/qutebrowser/config/configfiles.py
2018-09-28 19:28:32 +02:00

633 lines
23 KiB
Python

# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Configuration files residing on disk."""
import pathlib
import types
import os.path
import sys
import textwrap
import traceback
import configparser
import contextlib
import yaml
from PyQt5.QtCore import pyqtSignal, QObject, QSettings
import qutebrowser
from qutebrowser.config import configexc, config, configdata, configutils
from qutebrowser.keyinput import keyutils
from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch
# The StateConfig instance
state = None
class StateConfig(configparser.ConfigParser):
"""The "state" file saving various application state."""
def __init__(self):
super().__init__()
self._filename = os.path.join(standarddir.data(), 'state')
self.read(self._filename, encoding='utf-8')
for sect in ['general', 'geometry']:
try:
self.add_section(sect)
except configparser.DuplicateSectionError:
pass
deleted_keys = ['fooled', 'backend-warning-shown']
for key in deleted_keys:
self['general'].pop(key, None)
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
We do this outside of __init__ because the config gets created before
the save_manager exists.
"""
save_manager.add_saveable('state-config', self._save)
def _save(self):
"""Save the state file to the configured location."""
with open(self._filename, 'w', encoding='utf-8') as f:
self.write(f)
class YamlConfig(QObject):
"""A config stored on disk as YAML file.
Class attributes:
VERSION: The current version number of the config file.
"""
VERSION = 2
changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
self._dirty = None
self._values = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
We do this outside of __init__ because the config gets created before
the save_manager exists.
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
def __iter__(self):
"""Iterate over configutils.Values items."""
yield from self._values.values()
def _mark_changed(self):
"""Mark the YAML config as changed."""
self._dirty = True
self.changed.emit()
def _save(self):
"""Save the settings to the YAML file if they've changed."""
if not self._dirty:
return
settings = {}
for name, values in sorted(self._values.items()):
if not values:
continue
settings[name] = {}
for scoped in values:
key = ('global' if scoped.pattern is None
else str(scoped.pattern))
settings[name][key] = scoped.value
data = {'config_version': self.VERSION, 'settings': settings}
with qtutils.savefile_open(self._filename) as f:
f.write(textwrap.dedent("""
# DO NOT edit this file by hand, qutebrowser will overwrite it.
# Instead, create a config.py - see :help for details.
""".lstrip('\n')))
utils.yaml_dump(data, f)
def _pop_object(self, yaml_data, key, typ):
"""Get a global object from the given data."""
if not isinstance(yaml_data, dict):
desc = configexc.ConfigErrorDesc("While loading data",
"Toplevel object is not a dict")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
if key not in yaml_data:
desc = configexc.ConfigErrorDesc(
"While loading data",
"Toplevel object does not contain '{}' key".format(key))
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
data = yaml_data.pop(key)
if not isinstance(data, typ):
desc = configexc.ConfigErrorDesc(
"While loading data",
"'{}' object is not a {}".format(key, typ.__name__))
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
return data
def load(self):
"""Load configuration from the configured YAML file."""
try:
with open(self._filename, 'r', encoding='utf-8') as f:
yaml_data = utils.yaml_load(f)
except FileNotFoundError:
return
except OSError as e:
desc = configexc.ConfigErrorDesc("While reading", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
except yaml.YAMLError as e:
desc = configexc.ConfigErrorDesc("While parsing", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
config_version = self._pop_object(yaml_data, 'config_version', int)
if config_version == 1:
settings = self._load_legacy_settings_object(yaml_data)
self._mark_changed()
elif config_version > self.VERSION:
desc = configexc.ConfigErrorDesc(
"While reading",
"Can't read config from incompatible newer version")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
else:
settings = self._load_settings_object(yaml_data)
self._dirty = False
settings = self._handle_migrations(settings)
self._validate(settings)
self._build_values(settings)
def _load_settings_object(self, yaml_data):
"""Load the settings from the settings: key."""
return self._pop_object(yaml_data, 'settings', dict)
def _load_legacy_settings_object(self, yaml_data):
data = self._pop_object(yaml_data, 'global', dict)
settings = {}
for name, value in data.items():
settings[name] = {'global': value}
return settings
def _build_values(self, settings):
"""Build up self._values from the values in the given dict."""
errors = []
for name, yaml_values in settings.items():
if not isinstance(yaml_values, dict):
errors.append(configexc.ConfigErrorDesc(
"While parsing {!r}".format(name), "value is not a dict"))
continue
values = configutils.Values(configdata.DATA[name])
if 'global' in yaml_values:
values.add(yaml_values.pop('global'))
for pattern, value in yaml_values.items():
if not isinstance(pattern, str):
errors.append(configexc.ConfigErrorDesc(
"While parsing {!r}".format(name),
"pattern is not of type string"))
continue
try:
urlpattern = urlmatch.UrlPattern(pattern)
except urlmatch.ParseError as e:
errors.append(configexc.ConfigErrorDesc(
"While parsing pattern {!r} for {!r}"
.format(pattern, name), e))
continue
values.add(value, urlpattern)
self._values[name] = values
if errors:
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
def _migrate_bool(self, settings, name, true_value, false_value):
"""Migrate a boolean in the settings."""
if name in settings:
for scope, val in settings[name].items():
if isinstance(val, bool):
settings[name][scope] = true_value if val else false_value
self._mark_changed()
def _handle_migrations(self, settings):
"""Migrate older configs to the newest format."""
# Simple renamed/deleted options
for name in list(settings):
if name in configdata.MIGRATIONS.renamed:
new_name = configdata.MIGRATIONS.renamed[name]
log.config.debug("Renaming {} to {}".format(name, new_name))
settings[new_name] = settings[name]
del settings[name]
self._mark_changed()
elif name in configdata.MIGRATIONS.deleted:
log.config.debug("Removing {}".format(name))
del settings[name]
self._mark_changed()
# tabs.persist_mode_on_change got merged into tabs.mode_on_change
old = 'tabs.persist_mode_on_change'
new = 'tabs.mode_on_change'
if old in settings:
settings[new] = {}
for scope, val in settings[old].items():
if val:
settings[new][scope] = 'persist'
else:
settings[new][scope] = 'normal'
del settings[old]
self._mark_changed()
# bindings.default can't be set in autoconfig.yml anymore, so ignore
# old values.
if 'bindings.default' in settings:
del settings['bindings.default']
self._mark_changed()
# content.webrtc_public_interfaces_only got merged into
# content.webrtc_ip_handling_policy.
old = 'content.webrtc_public_interfaces_only'
new = 'content.webrtc_ip_handling_policy'
if old in settings:
settings[new] = {}
for scope, val in settings[old].items():
if val:
settings[new][scope] = 'default-public-interface-only'
else:
settings[new][scope] = 'all-interfaces'
del settings[old]
self._mark_changed()
self._migrate_bool(settings, 'tabs.favicons.show', 'always', 'never')
self._migrate_bool(settings, 'scrolling.bar',
'when-searching', 'never')
self._migrate_bool(settings, 'qt.force_software_rendering',
'software-opengl', 'none')
return settings
def _validate(self, settings):
"""Make sure all settings exist."""
unknown = []
for name in settings:
if name not in configdata.DATA:
unknown.append(name)
if unknown:
errors = [configexc.ConfigErrorDesc("While loading options",
"Unknown option {}".format(e))
for e in sorted(unknown)]
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
def set_obj(self, name, value, *, pattern=None):
"""Set the given setting to the given value."""
self._values[name].add(value, pattern)
self._mark_changed()
def unset(self, name, *, pattern=None):
"""Remove the given option name if it's configured."""
changed = self._values[name].remove(pattern)
if changed:
self._mark_changed()
def clear(self):
"""Clear all values from the YAML file."""
for values in self._values.values():
values.clear()
self._mark_changed()
class ConfigAPI:
"""Object which gets passed to config.py as "config" object.
This is a small wrapper over the Config object, but with more
straightforward method names (get/set call get_obj/set_obj) and a more
shallow API.
Attributes:
_config: The main Config object to use.
_keyconfig: The KeyConfig object.
errors: Errors which occurred while setting options.
configdir: The qutebrowser config directory, as pathlib.Path.
datadir: The qutebrowser data directory, as pathlib.Path.
"""
def __init__(self, conf, keyconfig):
self._config = conf
self._keyconfig = keyconfig
self.errors = []
self.configdir = pathlib.Path(standarddir.config())
self.datadir = pathlib.Path(standarddir.data())
@contextlib.contextmanager
def _handle_error(self, action, name):
"""Catch config-related exceptions and save them in self.errors."""
try:
yield
except configexc.ConfigFileErrors as e:
for err in e.errors:
new_err = err.with_text(e.basename)
self.errors.append(new_err)
except configexc.Error as e:
text = "While {} '{}'".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
except urlmatch.ParseError as e:
text = "While {} '{}' and parsing pattern".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
except keyutils.KeyParseError as e:
text = "While {} '{}' and parsing key".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
def finalize(self):
"""Do work which needs to be done after reading config.py."""
self._config.update_mutables()
def load_autoconfig(self):
"""Load the autoconfig.yml file which is used for :set/:bind/etc."""
with self._handle_error('reading', 'autoconfig.yml'):
read_autoconfig()
def get(self, name, pattern=None):
"""Get a setting value from the config, optionally with a pattern."""
with self._handle_error('getting', name):
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
return self._config.get_mutable_obj(name, pattern=urlpattern)
def set(self, name, value, pattern=None):
"""Set a setting value in the config, optionally with a pattern."""
with self._handle_error('setting', name):
urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
self._config.set_obj(name, value, pattern=urlpattern)
def bind(self, key, command, mode='normal'):
"""Bind a key to a command, with an optional key mode."""
with self._handle_error('binding', key):
seq = keyutils.KeySequence.parse(key)
self._keyconfig.bind(seq, command, mode=mode)
def unbind(self, key, mode='normal'):
"""Unbind a key from a command, with an optional key mode."""
with self._handle_error('unbinding', key):
seq = keyutils.KeySequence.parse(key)
self._keyconfig.unbind(seq, mode=mode)
def source(self, filename):
"""Read the given config file from disk."""
if not os.path.isabs(filename):
filename = str(self.configdir / filename)
try:
read_config_py(filename)
except configexc.ConfigFileErrors as e:
self.errors += e.errors
@contextlib.contextmanager
def pattern(self, pattern):
"""Get a ConfigContainer for the given pattern."""
# We need to propagate the exception so we don't need to return
# something.
urlpattern = urlmatch.UrlPattern(pattern)
container = config.ConfigContainer(config=self._config, configapi=self,
pattern=urlpattern)
yield container
class ConfigPyWriter:
"""Writer for config.py files from given settings."""
def __init__(self, options, bindings, *, commented):
self._options = options
self._bindings = bindings
self._commented = commented
def write(self, filename):
"""Write the config to the given file."""
with open(filename, 'w', encoding='utf-8') as f:
f.write('\n'.join(self._gen_lines()))
def _line(self, line):
"""Get an (optionally commented) line."""
if self._commented:
if line.startswith('#'):
return '#' + line
else:
return '# ' + line
else:
return line
def _gen_lines(self):
"""Generate a config.py with the given settings/bindings.
Yields individual lines.
"""
yield from self._gen_header()
yield from self._gen_options()
yield from self._gen_bindings()
def _gen_header(self):
"""Generate the initial header of the config."""
yield self._line("# Autogenerated config.py")
yield self._line("# Documentation:")
yield self._line("# qute://help/configuring.html")
yield self._line("# qute://help/settings.html")
yield ''
if self._commented:
# When generated from an autoconfig.yml with commented=False,
# we don't want to load that autoconfig.yml anymore.
yield self._line("# This is here so configs done via the GUI are "
"still loaded.")
yield self._line("# Remove it to not load settings done via the "
"GUI.")
yield self._line("config.load_autoconfig()")
yield ''
else:
yield self._line("# Uncomment this to still load settings "
"configured via autoconfig.yml")
yield self._line("# config.load_autoconfig()")
yield ''
def _gen_options(self):
"""Generate the options part of the config."""
for pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
continue
for line in textwrap.wrap(opt.description):
yield self._line("# {}".format(line))
yield self._line("# Type: {}".format(opt.typ.get_name()))
valid_values = opt.typ.get_valid_values()
if valid_values is not None and valid_values.generate_docs:
yield self._line("# Valid values:")
for val in valid_values:
try:
desc = valid_values.descriptions[val]
yield self._line("# - {}: {}".format(val, desc))
except KeyError:
yield self._line("# - {}".format(val))
if pattern is None:
yield self._line('c.{} = {!r}'.format(opt.name, value))
else:
yield self._line('config.set({!r}, {!r}, {!r})'.format(
opt.name, value, str(pattern)))
yield ''
def _gen_bindings(self):
"""Generate the bindings part of the config."""
normal_bindings = self._bindings.pop('normal', {})
if normal_bindings:
yield self._line('# Bindings for normal mode')
for key, command in sorted(normal_bindings.items()):
yield self._line('config.bind({!r}, {!r})'.format(
key, command))
yield ''
for mode, mode_bindings in sorted(self._bindings.items()):
yield self._line('# Bindings for {} mode'.format(mode))
for key, command in sorted(mode_bindings.items()):
yield self._line('config.bind({!r}, {!r}, mode={!r})'.format(
key, command, mode))
yield ''
def read_config_py(filename, raising=False):
"""Read a config.py file.
Arguments;
filename: The name of the file to read.
raising: Raise exceptions happening in config.py.
This is needed during tests to use pytest's inspection.
"""
assert config.instance is not None
assert config.key_instance is not None
api = ConfigAPI(config.instance, config.key_instance)
container = config.ConfigContainer(config.instance, configapi=api)
basename = os.path.basename(filename)
module = types.ModuleType('config')
module.config = api
module.c = container
module.__file__ = filename
try:
with open(filename, mode='rb') as f:
source = f.read()
except OSError as e:
text = "Error while reading {}".format(basename)
desc = configexc.ConfigErrorDesc(text, e)
raise configexc.ConfigFileErrors(basename, [desc])
try:
code = compile(source, filename, 'exec')
except ValueError as e:
# source contains NUL bytes
desc = configexc.ConfigErrorDesc("Error while compiling", e)
raise configexc.ConfigFileErrors(basename, [desc])
except SyntaxError as e:
desc = configexc.ConfigErrorDesc("Unhandled exception", e,
traceback=traceback.format_exc())
raise configexc.ConfigFileErrors(basename, [desc])
try:
# Save and restore sys variables
with saved_sys_properties():
# Add config directory to python path, so config.py can import
# other files in logical places
config_dir = os.path.dirname(filename)
if config_dir not in sys.path:
sys.path.insert(0, config_dir)
exec(code, module.__dict__)
except Exception as e:
if raising:
raise
api.errors.append(configexc.ConfigErrorDesc(
"Unhandled exception",
exception=e, traceback=traceback.format_exc()))
api.finalize()
if api.errors:
raise configexc.ConfigFileErrors('config.py', api.errors)
def read_autoconfig():
"""Read the autoconfig.yml file."""
try:
config.instance.read_yaml()
except configexc.ConfigFileErrors:
raise # caught in outer block
except configexc.Error as e:
desc = configexc.ConfigErrorDesc("Error", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
@contextlib.contextmanager
def saved_sys_properties():
"""Save various sys properties such as sys.path and sys.modules."""
old_path = sys.path.copy()
old_modules = sys.modules.copy()
try:
yield
finally:
sys.path = old_path
for module in set(sys.modules).difference(old_modules):
del sys.modules[module]
def init():
"""Initialize config storage not related to the main config."""
global state
state = StateConfig()
state['general']['version'] = qutebrowser.__version__
# Set the QSettings path to something like
# ~/.config/qutebrowser/qsettings/qutebrowser/qutebrowser.conf so it
# doesn't overwrite our config.
#
# This fixes one of the corruption issues here:
# https://github.com/qutebrowser/qutebrowser/issues/515
path = os.path.join(standarddir.config(auto=True), 'qsettings')
for fmt in [QSettings.NativeFormat, QSettings.IniFormat]:
QSettings.setPath(fmt, QSettings.UserScope, path)