qutebrowser/qutebrowser/config/configfiles.py

543 lines
19 KiB
Python
Raw Normal View History

2017-06-20 16:27:54 +02:00
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2018-02-05 12:19:50 +01:00
# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
2017-06-20 16:27:54 +02:00
#
# 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
2017-09-14 16:16:14 +02:00
import types
2017-06-20 16:27:54 +02:00
import os.path
import sys
2017-06-20 16:53:46 +02:00
import textwrap
import traceback
2017-06-20 16:27:54 +02:00
import configparser
import contextlib
2017-06-20 16:27:54 +02:00
import yaml
from PyQt5.QtCore import pyqtSignal, QObject, QSettings
2017-06-20 16:27:54 +02:00
2017-09-17 21:04:34 +02:00
import qutebrowser
from qutebrowser.config import configexc, config, configdata, configutils
from qutebrowser.utils import standarddir, utils, qtutils, log
2017-06-20 16:27:54 +02:00
2017-09-17 21:04:34 +02:00
# The StateConfig instance
state = None
2017-06-20 16:27:54 +02:00
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
2017-09-18 21:15:14 +02:00
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.
"""
2017-06-20 16:27:54 +02:00
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):
2017-06-20 16:53:46 +02:00
2017-07-19 08:22:00 +02:00
"""A config stored on disk as YAML file.
Class attributes:
VERSION: The current version number of the config file.
"""
VERSION = 2
changed = pyqtSignal()
2017-06-20 16:53:46 +02:00
def __init__(self, parent=None):
super().__init__(parent)
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
self._dirty = None
2017-06-20 16:53:46 +02:00
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)
2017-09-19 17:26:03 +02:00
def __iter__(self):
"""Iterate over configutils.Values items."""
yield from self._values.values()
2017-09-19 17:26:03 +02:00
def _mark_changed(self):
"""Mark the YAML config as changed."""
self._dirty = True
self.changed.emit()
2017-06-20 16:53:46 +02:00
def _save(self):
2017-09-19 14:08:59 +02:00
"""Save the settings to the YAML file if they've changed."""
if not self._dirty:
2017-09-19 14:08:59 +02:00
return
2018-02-19 06:25:28 +01:00
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}
2017-06-20 16:53:46 +02:00
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)
2018-02-20 15:46:05 +01:00
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
2017-06-20 16:53:46 +02:00
def load(self):
2017-09-19 14:08:59 +02:00
"""Load configuration from the configured YAML file."""
2017-06-20 16:53:46 +02:00
try:
with open(self._filename, 'r', encoding='utf-8') as f:
yaml_data = utils.yaml_load(f)
2017-06-20 16:53:46 +02:00
except FileNotFoundError:
2017-12-15 16:05:20 +01:00
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])
2018-02-20 15:46:05 +01:00
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()
else:
settings = self._load_settings_object(yaml_data)
self._dirty = False
2018-02-19 06:49:55 +01:00
settings = self._handle_migrations(settings)
self._validate(settings)
self._build_values(settings)
def _load_settings_object(self, yaml_data):
2018-02-20 15:46:05 +01:00
"""Load the settings from the settings: key."""
return self._pop_object(yaml_data, 'settings', dict)
2018-02-19 06:49:55 +01:00
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
2018-02-19 06:49:55 +01:00
def _build_values(self, settings):
"""Build up self._values from the values in the given dict."""
for name, yaml_values in settings.items():
values = configutils.Values(configdata.DATA[name])
if 'global' in yaml_values:
values.add(yaml_values.pop('global'))
2018-02-19 21:02:07 +01:00
# FIXME:conf what if yaml_values is not a dict...
for pattern, value in yaml_values.items():
values.add(value, pattern)
self._values[name] = values
2018-02-19 06:49:55 +01:00
def _handle_migrations(self, settings):
2018-02-10 11:44:18 +01:00
"""Migrate older configs to the newest format."""
# Simple renamed/deleted options
2018-02-19 06:49:55 +01:00
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))
2018-02-19 06:49:55 +01:00
settings[new_name] = settings[name]
del settings[name]
2018-02-10 11:44:18 +01:00
self._mark_changed()
elif name in configdata.MIGRATIONS.deleted:
log.config.debug("Removing {}".format(name))
2018-02-19 06:49:55 +01:00
del settings[name]
2018-02-10 11:44:18 +01:00
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'
2018-02-19 06:49:55 +01:00
if old in settings:
settings[new] = {}
for scope, val in settings[old].items():
if val:
settings[new][scope] = 'persist'
else:
settings[new][scope] = 'normal'
2018-02-19 06:49:55 +01:00
del settings[old]
2018-02-10 11:44:18 +01:00
self._mark_changed()
2018-02-19 06:49:55 +01:00
return settings
def _validate(self, settings):
"""Make sure all settings exist."""
unknown = []
2018-02-19 06:49:55 +01:00
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)
2018-02-19 06:25:28 +01:00
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."""
2018-02-19 05:08:17 +01:00
for values in self._values.values():
values.clear()
self._mark_changed()
2017-06-20 16:53:46 +02:00
2017-09-14 16:16:14 +02:00
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.
2017-09-14 16:16:14 +02:00
"""
2017-09-18 09:41:12 +02:00
def __init__(self, conf, keyconfig):
self._config = conf
2017-09-14 16:16:14 +02:00
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):
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))
def finalize(self):
2017-09-15 00:10:24 +02:00
"""Do work which needs to be done after reading config.py."""
self._config.update_mutables()
2017-09-14 16:16:14 +02:00
def load_autoconfig(self):
with self._handle_error('reading', 'autoconfig.yml'):
read_autoconfig()
2017-09-14 16:16:14 +02:00
def get(self, name):
with self._handle_error('getting', name):
return self._config.get_mutable_obj(name)
2017-09-14 16:16:14 +02:00
def set(self, name, value):
with self._handle_error('setting', name):
self._config.set_obj(name, value)
2017-09-14 16:16:14 +02:00
def bind(self, key, command, mode='normal'):
with self._handle_error('binding', key):
self._keyconfig.bind(key, command, mode=mode)
2017-09-14 16:16:14 +02:00
2017-09-19 13:18:44 +02:00
def unbind(self, key, mode='normal'):
with self._handle_error('unbinding', key):
self._keyconfig.unbind(key, mode=mode)
2017-09-14 16:16:14 +02:00
2017-10-18 13:53:26 +02:00
def source(self, filename):
2017-10-20 09:12:23 +02:00
"""Read the given config file from disk."""
2017-10-18 13:53:26 +02:00
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
2017-09-14 16:16:14 +02:00
2017-10-05 09:01:33 +02:00
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:
2017-10-05 10:21:31 +02:00
f.write('\n'.join(self._gen_lines()))
2017-10-05 09:01:33 +02:00
def _line(self, line):
"""Get an (optionally commented) line."""
if self._commented:
if line.startswith('#'):
return '#' + line
else:
return '# ' + line
else:
return line
2017-10-05 10:21:31 +02:00
def _gen_lines(self):
2017-10-05 09:01:33 +02:00
"""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):
2017-10-05 10:21:31 +02:00
"""Generate the initial header of the config."""
2017-10-05 09:01:33 +02:00
yield self._line("# Autogenerated config.py")
yield self._line("# Documentation:")
yield self._line("# qute://help/configuring.html")
2017-10-05 10:21:31 +02:00
yield self._line("# qute://help/settings.html")
yield ''
2017-10-05 09:01:33 +02:00
if self._commented:
# When generated from an autoconfig.yml with commented=False,
# we don't want to load that autoconfig.yml anymore.
2017-10-05 09:01:33 +02:00
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.")
2017-10-05 10:21:31 +02:00
yield self._line("config.load_autoconfig()")
yield ''
else:
2017-10-05 09:01:33 +02:00
yield self._line("# Uncomment this to still load settings "
"configured via autoconfig.yml")
2017-10-05 10:21:31 +02:00
yield self._line("# config.load_autoconfig()")
yield ''
2017-10-05 09:01:33 +02:00
def _gen_options(self):
"""Generate the options part of the config."""
2018-02-19 19:38:45 +01:00
# FIXME:conf handle _pattern
for _pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
continue
for line in textwrap.wrap(opt.description):
2017-10-05 09:01:33 +02:00
yield self._line("# {}".format(line))
2017-10-05 09:01:33 +02:00
yield self._line("# Type: {}".format(opt.typ.get_name()))
valid_values = opt.typ.get_valid_values()
2017-10-04 11:46:42 +02:00
if valid_values is not None and valid_values.generate_docs:
2017-10-05 09:01:33 +02:00
yield self._line("# Valid values:")
for val in valid_values:
try:
desc = valid_values.descriptions[val]
2017-10-05 09:01:33 +02:00
yield self._line("# - {}: {}".format(val, desc))
except KeyError:
2017-10-05 09:01:33 +02:00
yield self._line("# - {}".format(val))
2017-10-05 10:21:31 +02:00
yield self._line('c.{} = {!r}'.format(opt.name, value))
yield ''
2017-10-05 09:01:33 +02:00
def _gen_bindings(self):
"""Generate the bindings part of the config."""
normal_bindings = self._bindings.pop('normal', {})
if normal_bindings:
2017-10-05 09:01:33 +02:00
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 ''
2017-10-05 09:01:33 +02:00
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()):
2017-10-05 09:01:33 +02:00
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
2017-09-15 14:08:25 +02:00
api = ConfigAPI(config.instance, config.key_instance)
container = config.ConfigContainer(config.instance, configapi=api)
2017-09-15 14:08:25 +02:00
basename = os.path.basename(filename)
2017-09-14 16:16:14 +02:00
module = types.ModuleType('config')
module.config = api
module.c = container
2017-09-14 16:16:14 +02:00
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 as e:
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]
2017-09-14 16:16:14 +02:00
2017-09-15 17:22:50 +02:00
def init():
2017-06-20 16:27:54 +02:00
"""Initialize config storage not related to the main config."""
2017-09-17 21:04:34 +02:00
global state
2017-06-20 16:27:54 +02:00
state = StateConfig()
2017-09-17 21:04:34 +02:00
state['general']['version'] = qutebrowser.__version__
2017-06-20 16:27:54 +02:00
# 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')
2017-06-20 16:27:54 +02:00
for fmt in [QSettings.NativeFormat, QSettings.IniFormat]:
QSettings.setPath(fmt, QSettings.UserScope, path)