qutebrowser/qutebrowser/config/config.py

615 lines
22 KiB
Python

# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2020 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 storage and config-related utilities."""
import copy
import contextlib
import functools
from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping,
MutableMapping, MutableSequence, Optional, Tuple, cast)
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
from qutebrowser.config import configdata, configexc, configutils
from qutebrowser.utils import utils, log, urlmatch
from qutebrowser.misc import objects
from qutebrowser.keyinput import keyutils
if TYPE_CHECKING:
from qutebrowser.config import configcache, configfiles
from qutebrowser.misc import savemanager
# An easy way to access the config from other code via config.val.foo
val = cast('ConfigContainer', None)
instance = cast('Config', None)
key_instance = cast('KeyConfig', None)
cache = cast('configcache.ConfigCache', None)
# Keeping track of all change filters to validate them later.
change_filters = []
# Sentinel
UNSET = object()
class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
"""Decorator to filter calls based on a config section/option matching.
This could also be a function, but as a class (with a "wrong" name) it's
much cleaner to implement.
Attributes:
_option: An option or prefix to be filtered
_function: Whether a function rather than a method is decorated.
"""
def __init__(self, option: str, function: bool = False) -> None:
"""Save decorator arguments.
Gets called on parse-time with the decorator arguments.
Args:
option: The option to be filtered.
function: Whether a function rather than a method is decorated.
"""
self._option = option
self._function = function
change_filters.append(self)
def validate(self) -> None:
"""Make sure the configured option or prefix exists.
We can't do this in __init__ as configdata isn't ready yet.
"""
if (self._option not in configdata.DATA and
not configdata.is_valid_prefix(self._option)):
raise configexc.NoOptionError(self._option)
def check_match(self, option: Optional[str]) -> bool:
"""Check if the given option matches the filter."""
if option is None:
# Called directly, not from a config change event.
return True
elif option == self._option:
return True
elif option.startswith(self._option + '.'):
# prefix match
return True
else:
return False
def __call__(self, func: Callable) -> Callable:
"""Filter calls to the decorated function.
Gets called when a function should be decorated.
Adds a filter which returns if we're not interested in the change-event
and calls the wrapped function if we are.
We assume the function passed doesn't take any parameters.
Args:
func: The function to be decorated.
Return:
The decorated function.
"""
if self._function:
@functools.wraps(func)
def func_wrapper(option: str = None) -> Any:
"""Call the underlying function."""
if self.check_match(option):
return func()
return None
return func_wrapper
else:
@functools.wraps(func)
def meth_wrapper(wrapper_self: Any, option: str = None) -> Any:
"""Call the underlying function."""
if self.check_match(option):
return func(wrapper_self)
return None
return meth_wrapper
class KeyConfig:
"""Utilities related to keybindings.
Note that the actual values are saved in the config itself, not here.
Attributes:
_config: The Config object to be used.
"""
_ReverseBindings = Dict[str, MutableSequence[str]]
def __init__(self, config: 'Config') -> None:
self._config = config
def _validate(self, key: keyutils.KeySequence, mode: str) -> None:
"""Validate the given key and mode."""
# Catch old usage of this code
assert isinstance(key, keyutils.KeySequence), key
if mode not in configdata.DATA['bindings.default'].default:
raise configexc.KeybindingError("Invalid mode {}!".format(mode))
def get_bindings_for(self, mode: str) -> Dict[keyutils.KeySequence, str]:
"""Get the combined bindings for the given mode."""
bindings = dict(val.bindings.default[mode])
for key, binding in val.bindings.commands[mode].items():
if not binding:
bindings.pop(key, None)
else:
bindings[key] = binding
return bindings
def get_reverse_bindings_for(self, mode: str) -> '_ReverseBindings':
"""Get a dict of commands to a list of bindings for the mode."""
cmd_to_keys: KeyConfig._ReverseBindings = {}
bindings = self.get_bindings_for(mode)
for seq, full_cmd in sorted(bindings.items()):
for cmd in full_cmd.split(';;'):
cmd = cmd.strip()
cmd_to_keys.setdefault(cmd, [])
# Put bindings involving modifiers last
if any(info.modifiers for info in seq):
cmd_to_keys[cmd].append(str(seq))
else:
cmd_to_keys[cmd].insert(0, str(seq))
return cmd_to_keys
def get_command(self,
key: keyutils.KeySequence,
mode: str,
default: bool = False) -> Optional[str]:
"""Get the command for a given key (or None)."""
self._validate(key, mode)
if default:
bindings = dict(val.bindings.default[mode])
else:
bindings = self.get_bindings_for(mode)
return bindings.get(key, None)
def bind(self,
key: keyutils.KeySequence,
command: str, *,
mode: str,
save_yaml: bool = False) -> None:
"""Add a new binding from key to command."""
if not command.strip():
raise configexc.KeybindingError(
"Can't add binding '{}' with empty command in {} "
'mode'.format(key, mode))
self._validate(key, mode)
log.keyboard.vdebug( # type: ignore[attr-defined]
"Adding binding {} -> {} in mode {}.".format(key, command, mode))
bindings = self._config.get_mutable_obj('bindings.commands')
if mode not in bindings:
bindings[mode] = {}
bindings[mode][str(key)] = command
self._config.update_mutables(save_yaml=save_yaml)
def bind_default(self,
key: keyutils.KeySequence, *,
mode: str = 'normal',
save_yaml: bool = False) -> None:
"""Restore a default keybinding."""
self._validate(key, mode)
bindings_commands = self._config.get_mutable_obj('bindings.commands')
try:
del bindings_commands[mode][str(key)]
except KeyError:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
self._config.update_mutables(save_yaml=save_yaml)
def unbind(self,
key: keyutils.KeySequence, *,
mode: str = 'normal',
save_yaml: bool = False) -> None:
"""Unbind the given key in the given mode."""
self._validate(key, mode)
bindings_commands = self._config.get_mutable_obj('bindings.commands')
if val.bindings.commands[mode].get(key, None) is not None:
# In custom bindings -> remove it
del bindings_commands[mode][str(key)]
elif key in val.bindings.default[mode]:
# In default bindings -> shadow it with None
if mode not in bindings_commands:
bindings_commands[mode] = {}
bindings_commands[mode][str(key)] = None
else:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
self._config.update_mutables(save_yaml=save_yaml)
class Config(QObject):
"""Main config object.
Class attributes:
MUTABLE_TYPES: Types returned from the config which could potentially
be mutated.
Attributes:
_values: A dict mapping setting names to configutils.Values objects.
_mutables: A dictionary of mutable objects to be checked for changes.
_yaml: A YamlConfig object or None.
Signals:
changed: Emitted with the option name when an option changed.
"""
MUTABLE_TYPES = (dict, list)
changed = pyqtSignal(str)
def __init__(self,
yaml_config: 'configfiles.YamlConfig',
parent: QObject = None) -> None:
super().__init__(parent)
self._mutables: MutableMapping[str, Tuple[Any, Any]] = {}
self._yaml = yaml_config
self._init_values()
self.yaml_loaded = False
self.config_py_loaded = False
self.warn_autoconfig = True
def _init_values(self) -> None:
"""Populate the self._values dict."""
self._values: Mapping = {}
for name, opt in configdata.DATA.items():
self._values[name] = configutils.Values(opt)
def __iter__(self) -> Iterator[configutils.Values]:
"""Iterate over configutils.Values items."""
yield from self._values.values()
def init_save_manager(self,
save_manager: 'savemanager.SaveManager') -> None:
"""Make sure the config gets saved properly.
We do this outside of __init__ because the config gets created before
the save_manager exists.
"""
self._yaml.init_save_manager(save_manager)
def _set_value(self,
opt: 'configdata.Option',
value: Any,
pattern: urlmatch.UrlPattern = None,
hide_userconfig: bool = False) -> None:
"""Set the given option to the given value."""
if not isinstance(objects.backend, objects.NoBackend):
if objects.backend not in opt.backends:
raise configexc.BackendError(opt.name, objects.backend,
opt.raw_backends)
opt.typ.to_py(value) # for validation
self._values[opt.name].add(opt.typ.from_obj(value),
pattern, hide_userconfig=hide_userconfig)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
opt.name, value))
def _check_yaml(self, opt: 'configdata.Option', save_yaml: bool) -> None:
"""Make sure the given option may be set in autoconfig.yml."""
if save_yaml and opt.no_autoconfig:
raise configexc.NoAutoconfigError(opt.name)
def read_yaml(self) -> None:
"""Read the YAML settings from self._yaml."""
self._yaml.load()
self.yaml_loaded = True
for values in self._yaml:
for scoped in values:
self._set_value(values.opt, scoped.value,
pattern=scoped.pattern)
def get_opt(self, name: str) -> 'configdata.Option':
"""Get a configdata.Option object for the given setting."""
try:
return configdata.DATA[name]
except KeyError:
deleted = name in configdata.MIGRATIONS.deleted
renamed = configdata.MIGRATIONS.renamed.get(name)
exception = configexc.NoOptionError(
name, deleted=deleted, renamed=renamed)
raise exception from None
def ensure_has_opt(self, name: str) -> None:
"""Raise NoOptionError if the given setting does not exist."""
self.get_opt(name)
def get(self,
name: str,
url: QUrl = None, *,
fallback: bool = True) -> Any:
"""Get the given setting converted for Python code.
Args:
fallback: Use the global value if there's no URL-specific one.
"""
opt = self.get_opt(name)
obj = self.get_obj(name, url=url, fallback=fallback)
return opt.typ.to_py(obj)
def _maybe_copy(self, value: Any) -> Any:
"""Copy the value if it could potentially be mutated."""
if isinstance(value, self.MUTABLE_TYPES):
# For mutable objects, create a copy so we don't accidentally
# mutate the config's internal value.
return copy.deepcopy(value)
else:
# Shouldn't be mutable (and thus hashable)
assert value.__hash__ is not None, value
return value
def get_obj(self,
name: str, *,
url: QUrl = None,
fallback: bool = True) -> Any:
"""Get the given setting as object (for YAML/config.py).
Note that the returned values are not watched for mutation.
If a URL is given, return the value which should be used for that URL.
"""
self.ensure_has_opt(name)
value = self._values[name].get_for_url(url, fallback=fallback)
return self._maybe_copy(value)
def get_obj_for_pattern(
self, name: str, *,
pattern: Optional[urlmatch.UrlPattern]
) -> Any:
"""Get the given setting as object (for YAML/config.py).
This gets the overridden value for a given pattern, or
usertypes.UNSET if no such override exists.
"""
self.ensure_has_opt(name)
value = self._values[name].get_for_pattern(pattern, fallback=False)
return self._maybe_copy(value)
def get_mutable_obj(self, name: str, *,
pattern: urlmatch.UrlPattern = None) -> Any:
"""Get an object which can be mutated, e.g. in a config.py.
If a pattern is given, return the value for that pattern.
Note that it's impossible to get a mutable object for a URL as we
wouldn't know what pattern to apply.
"""
self.ensure_has_opt(name)
# If we allow mutation, there is a chance that prior mutations already
# entered the mutable dictionary and thus further copies are unneeded
# until update_mutables() is called
if name in self._mutables:
_copy, obj = self._mutables[name]
return obj
value = self._values[name].get_for_pattern(pattern)
copy_value = self._maybe_copy(value)
# Watch the returned object for changes if it's mutable.
if isinstance(copy_value, self.MUTABLE_TYPES):
self._mutables[name] = (value, copy_value) # old, new
return copy_value
def get_str(self, name: str, *,
pattern: urlmatch.UrlPattern = None) -> str:
"""Get the given setting as string.
If a pattern is given, get the setting for the given pattern or
usertypes.UNSET.
"""
opt = self.get_opt(name)
values = self._values[name]
value = values.get_for_pattern(pattern)
return opt.typ.to_str(value)
def set_obj(self, name: str,
value: Any, *,
pattern: urlmatch.UrlPattern = None,
save_yaml: bool = False,
hide_userconfig: bool = False) -> None:
"""Set the given setting from a YAML/config.py object.
If save_yaml=True is given, store the new value to YAML.
If hide_userconfig=True is given, hide the value from
dump_userconfig().
"""
opt = self.get_opt(name)
self._check_yaml(opt, save_yaml)
self._set_value(opt, value, pattern=pattern,
hide_userconfig=hide_userconfig)
if save_yaml:
self._yaml.set_obj(name, value, pattern=pattern)
def set_str(self, name: str,
value: str, *,
pattern: urlmatch.UrlPattern = None,
save_yaml: bool = False) -> None:
"""Set the given setting from a string.
If save_yaml=True is given, store the new value to YAML.
"""
opt = self.get_opt(name)
self._check_yaml(opt, save_yaml)
converted = opt.typ.from_str(value)
log.config.debug("Setting {} (type {}) to {!r} (converted from {!r})"
.format(name, opt.typ.__class__.__name__, converted,
value))
self._set_value(opt, converted, pattern=pattern)
if save_yaml:
self._yaml.set_obj(name, converted, pattern=pattern)
def unset(self, name: str, *,
save_yaml: bool = False,
pattern: urlmatch.UrlPattern = None) -> None:
"""Set the given setting back to its default."""
opt = self.get_opt(name)
self._check_yaml(opt, save_yaml)
changed = self._values[name].remove(pattern)
if changed:
self.changed.emit(name)
if save_yaml:
self._yaml.unset(name, pattern=pattern)
def clear(self, *, save_yaml: bool = False) -> None:
"""Clear all settings in the config.
If save_yaml=True is given, also remove all customization from the YAML
file.
"""
for name, values in self._values.items():
if values:
values.clear()
self.changed.emit(name)
if save_yaml:
self._yaml.clear()
def update_mutables(self, *, save_yaml: bool = False) -> None:
"""Update mutable settings if they changed.
Every time someone calls get_obj() on a mutable object, we save a
reference to the original object and a copy.
Here, we check all those saved copies for mutations, and if something
mutated, we call set_obj again so we save the new value.
"""
for name, (old_value, new_value) in self._mutables.items():
if old_value != new_value:
log.config.debug("{} was mutated, updating".format(name))
self.set_obj(name, new_value, save_yaml=save_yaml)
self._mutables = {}
def dump_userconfig(self) -> str:
"""Get the part of the config which was changed by the user.
Return:
The changed config part as string.
"""
lines: List[str] = []
for values in sorted(self, key=lambda v: v.opt.name):
lines += values.dump()
if not lines:
return '<Default configuration>'
return '\n'.join(lines)
class ConfigContainer:
"""An object implementing config access via __getattr__.
Attributes:
_config: The Config object.
_prefix: The __getattr__ chain leading up to this object.
_configapi: If given, get values suitable for config.py and
add errors to the given ConfigAPI object.
_pattern: The URL pattern to be used.
"""
def __init__(self, config: Config,
configapi: 'configfiles.ConfigAPI' = None,
prefix: str = '',
pattern: urlmatch.UrlPattern = None) -> None:
self._config = config
self._prefix = prefix
self._configapi = configapi
self._pattern = pattern
if configapi is None and pattern is not None:
raise TypeError("Can't use pattern without configapi!")
def __repr__(self) -> str:
return utils.get_repr(self, constructor=True, config=self._config,
configapi=self._configapi, prefix=self._prefix,
pattern=self._pattern)
@contextlib.contextmanager
def _handle_error(self, action: str, name: str) -> Iterator[None]:
try:
yield
except configexc.Error as e:
if self._configapi is None:
raise
text = "While {} '{}'".format(action, name)
self._configapi.errors.append(configexc.ConfigErrorDesc(text, e))
def __getattr__(self, attr: str) -> Any:
"""Get an option or a new ConfigContainer with the added prefix.
If we get an option which exists, we return the value for it.
If we get a part of an option name, we return a new ConfigContainer.
Those two never overlap as configdata.py ensures there are no shadowing
options.
"""
if attr.startswith('_'):
return self.__getattribute__(attr)
name = self._join(attr)
if configdata.is_valid_prefix(name):
return ConfigContainer(config=self._config,
configapi=self._configapi,
prefix=name, pattern=self._pattern)
with self._handle_error('getting', name):
if self._configapi is None:
# access from Python code
return self._config.get(name)
else:
# access from config.py
return self._config.get_mutable_obj(
name, pattern=self._pattern)
def __setattr__(self, attr: str, value: Any) -> None:
"""Set the given option in the config."""
if attr.startswith('_'):
super().__setattr__(attr, value)
return
name = self._join(attr)
with self._handle_error('setting', name):
self._config.set_obj(name, value, pattern=self._pattern)
def _join(self, attr: str) -> str:
"""Get the prefix joined with the given attribute."""
if self._prefix:
return '{}.{}'.format(self._prefix, attr)
else:
return attr