Initial implementation of per-URL setting storage

This commit is contained in:
Florian Bruhin 2018-02-15 21:28:05 +01:00
parent 894da598d6
commit 4ed07d6062
4 changed files with 161 additions and 49 deletions

View File

@ -23,6 +23,7 @@ import copy
import contextlib
import functools
import attr
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject
from qutebrowser.config import configdata, configexc
@ -225,12 +226,25 @@ class KeyConfig:
self._config.update_mutables(save_yaml=save_yaml)
@attr.s
class PerUrlSettings:
"""A simple container with an URL pattern and settings for it."""
pattern = attr.ib()
values = attr.ib(default=attr.Factory(dict))
class Config(QObject):
"""Main config object.
Attributes:
_values: A dict mapping setting names to their values.
_per_url_values: A mapping from UrlPattern objects to PerUrlSetting
instances. Note that dict lookup is currently only
useful for finding the pattern when adding values, not
for getting values.
_mutables: A dictionary of mutable objects to be checked for changes.
_yaml: A YamlConfig object or None.
@ -244,13 +258,18 @@ class Config(QObject):
super().__init__(parent)
self.changed.connect(_render_stylesheet.cache_clear)
self._values = {}
self._per_url_values = {}
self._mutables = {}
self._yaml = yaml_config
def __iter__(self):
"""Iterate over Option, value tuples."""
"""Iterate over UrlPattern, Option, value tuples."""
for name, value in sorted(self._values.items()):
yield (self.get_opt(name), value)
yield (None, self.get_opt(name), value)
for pattern, options in sorted(self._per_url_values.items()):
for name, value in sorted(options.values.items()):
yield (pattern, self.get_opt(name), value)
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@ -260,14 +279,31 @@ class Config(QObject):
"""
self._yaml.init_save_manager(save_manager)
def _set_value(self, opt, value):
def _get_values(self, pattern=None, create=False):
"""Get the appropriate _values instance for the given pattern.
With create=True, create a new one instead of returning an empty dict.
"""
if pattern is None:
return self._values
elif pattern in self._per_url_values:
return self._per_url_values[pattern].values
elif create:
settings = PerUrlSettings(pattern)
self._per_url_values[pattern] = settings
return settings.values
else:
return {}
def _set_value(self, opt, value, pattern=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.typ.to_py(value) # for validation
self._values[opt.name] = opt.typ.from_obj(value)
values = self._get_values(pattern, create=True)
values[opt.name] = opt.typ.from_obj(value)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
@ -276,8 +312,9 @@ class Config(QObject):
def read_yaml(self):
"""Read the YAML settings from self._yaml."""
self._yaml.load()
for name, value in self._yaml:
self._set_value(self.get_opt(name), value)
# FIXME:conf implement in self._yaml
for pattern, name, value in self._yaml:
self._set_value(self.get_opt(name), value, pattern=pattern)
def get_opt(self, name):
"""Get a configdata.Option object for the given setting."""
@ -290,16 +327,18 @@ class Config(QObject):
name, deleted=deleted, renamed=renamed)
raise exception from None
def get(self, name):
def get(self, name, pattern=None):
"""Get the given setting converted for Python code."""
opt = self.get_opt(name)
obj = self.get_obj(name, mutable=False)
obj = self.get_obj(name, mutable=False, pattern=pattern)
return opt.typ.to_py(obj)
def get_obj(self, name, *, mutable=True):
def get_obj(self, name, *, mutable=True, pattern=None):
"""Get the given setting as object (for YAML/config.py).
If mutable=True is set, watch the returned object for mutations.
If a pattern is given, get the per-domain setting for that pattern (if
any).
"""
opt = self.get_opt(name)
obj = None
@ -311,7 +350,8 @@ class Config(QObject):
# Otherwise, we return a copy of the value stored internally, so the
# internal value can never be changed by mutating the object returned.
else:
obj = copy.deepcopy(self._values.get(name, opt.default))
values = self._get_values(pattern)
obj = copy.deepcopy(values.get(name, opt.default))
# Then we watch the returned object for changes.
if isinstance(obj, (dict, list)):
if mutable:
@ -321,22 +361,23 @@ class Config(QObject):
assert obj.__hash__ is not None, obj
return obj
def get_str(self, name):
def get_str(self, name, *, pattern=None):
"""Get the given setting as string."""
opt = self.get_opt(name)
value = self._values.get(name, opt.default)
values = self._get_values(pattern)
value = values.get(name, opt.default)
return opt.typ.to_str(value)
def set_obj(self, name, value, *, save_yaml=False):
def set_obj(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a YAML/config.py object.
If save_yaml=True is given, store the new value to YAML.
"""
self._set_value(self.get_opt(name), value)
self._set_value(self.get_opt(name), value, pattern=pattern)
if save_yaml:
self._yaml[name] = value
self._yaml.set_obj(name, value, pattern=pattern)
def set_str(self, name, value, *, save_yaml=False):
def set_str(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a string.
If save_yaml=True is given, store the new value to YAML.
@ -346,21 +387,22 @@ class Config(QObject):
log.config.debug("Setting {} (type {}) to {!r} (converted from {!r})"
.format(name, opt.typ.__class__.__name__, converted,
value))
self._set_value(opt, converted)
self._set_value(opt, converted, pattern=pattern)
if save_yaml:
self._yaml[name] = converted
self._yaml.set_obj(name, converted, pattern=pattern)
def unset(self, name, *, save_yaml=False):
def unset(self, name, *, save_yaml=False, pattern=None):
"""Set the given setting back to its default."""
self.get_opt(name)
values = self._get_values(pattern)
try:
del self._values[name]
del values[name]
except KeyError:
return
self.changed.emit(name)
if save_yaml:
self._yaml.unset(name)
self._yaml.unset(name, pattern=pattern)
def clear(self, *, save_yaml=False):
"""Clear all settings in the config.
@ -368,6 +410,7 @@ class Config(QObject):
If save_yaml=True is given, also remove all customization from the YAML
file.
"""
# FIXME:conf support per-URL settings?
old_values = self._values
self._values = {}
for name in old_values:
@ -398,9 +441,13 @@ class Config(QObject):
The changed config part as string.
"""
lines = []
for opt, value in self:
for pattern, opt, value in self:
str_value = opt.typ.to_str(value)
lines.append('{} = {}'.format(opt.name, str_value))
if pattern is None:
lines.append('{} = {}'.format(opt.name, str_value))
else:
lines.append('{}: {} = {}'.format(pattern, opt.name,
str_value))
if not lines:
lines = ['<Default configuration>']
return '\n'.join(lines)

View File

@ -26,7 +26,7 @@ from PyQt5.QtCore import QUrl
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.completion.models import configmodel
from qutebrowser.utils import objreg, utils, message, standarddir
from qutebrowser.utils import objreg, utils, message, standarddir, urlmatch
from qutebrowser.config import configtypes, configexc, configfiles, configdata
from qutebrowser.misc import editor
@ -47,17 +47,29 @@ class ConfigCommands:
except configexc.Error as e:
raise cmdexc.CommandError(str(e))
def _print_value(self, option):
def _parse_pattern(self, url):
"""Parse an URL argument to a pattern."""
if url is None:
return None
try:
return urlmatch.UrlPattern(url)
except urlmatch.ParseError as e:
raise cmdexc.CommandError("Error while parsing {}: {}"
.format(url, str(e)))
def _print_value(self, option, pattern):
"""Print the value of the given option."""
with self._handle_config_error():
value = self._config.get_str(option)
value = self._config.get_str(option, pattern=pattern)
message.info("{} = {}".format(option, value))
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('value', completion=configmodel.value)
@cmdutils.argument('win_id', win_id=True)
def set(self, win_id, option=None, value=None, temp=False, print_=False):
def set(self, win_id, option=None, value=None, temp=False, print_=False,
*, url=None):
"""Set an option.
If the option name ends with '?', the value of the option is shown
@ -69,6 +81,7 @@ class ConfigCommands:
Args:
option: The name of the option.
value: The value to set.
url: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
@ -82,8 +95,10 @@ class ConfigCommands:
raise cmdexc.CommandError("Toggling values was moved to the "
":config-cycle command")
pattern = self._parse_pattern(url)
if option.endswith('?') and option != '?':
self._print_value(option[:-1])
self._print_value(option[:-1], pattern=pattern)
return
with self._handle_config_error():
@ -91,10 +106,11 @@ class ConfigCommands:
raise cmdexc.CommandError("set: The following arguments "
"are required: value")
else:
self._config.set_str(option, value, save_yaml=not temp)
self._config.set_str(option, value, pattern=pattern,
save_yaml=not temp)
if print_:
self._print_value(option)
self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands', maxsplit=1,
no_cmd_split=True, no_replace_variables=True)
@ -161,18 +177,22 @@ class ConfigCommands:
@cmdutils.register(instance='config-commands', star_args_optional=True)
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('values', completion=configmodel.value)
def config_cycle(self, option, *values, temp=False, print_=False):
def config_cycle(self, option, *values, url=None, temp=False, print_=False):
"""Cycle an option between multiple values.
Args:
option: The name of the option.
values: The values to cycle through.
url: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
pattern = self._parse_pattern(url)
with self._handle_config_error():
opt = self._config.get_opt(option)
old_value = self._config.get_obj(option, mutable=False)
old_value = self._config.get_obj(option, mutable=False,
pattern=pattern)
if not values and isinstance(opt.typ, configtypes.Bool):
values = ['true', 'false']
@ -194,10 +214,11 @@ class ConfigCommands:
value = values[0]
with self._handle_config_error():
self._config.set_obj(option, value, save_yaml=not temp)
self._config.set_obj(option, value, pattern=pattern,
save_yaml=not temp)
if print_:
self._print_value(option)
self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.customized_option)

View File

@ -88,6 +88,7 @@ class YamlConfig(QObject):
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
self._values = {}
self._per_url_values = {}
self._dirty = None
def init_save_manager(self, save_manager):
@ -98,18 +99,24 @@ class YamlConfig(QObject):
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
def __getitem__(self, name):
return self._values[name]
def __setitem__(self, name, value):
self._values[name] = value
self._mark_changed()
def __contains__(self, name):
return name in self._values
def __iter__(self):
return iter(sorted(self._values.items()))
for name, value in sorted(self._values.items()):
yield (None, name, value)
for pattern, options in sorted(self._per_url_values.items()):
for name, value in sorted(options.values.items()):
yield (pattern, name, value)
def _get_values(self, pattern=None):
"""Get the appropriate _values instance for the given pattern."""
if pattern is None:
return self._values
elif pattern in self._per_url_values:
return self._per_url_values[pattern]
else:
values = {}
self._per_url_values[pattern] = values
return values
def _mark_changed(self):
"""Mark the YAML config as changed."""
@ -122,6 +129,9 @@ class YamlConfig(QObject):
return
data = {'config_version': self.VERSION, 'global': self._values}
for pattern, values in sorted(self._per_url_values.items()):
data[str(pattern)] = values
with qtutils.savefile_open(self._filename) as f:
f.write(textwrap.dedent("""
# DO NOT edit this file by hand, qutebrowser will overwrite it.
@ -145,7 +155,7 @@ class YamlConfig(QObject):
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
try:
global_obj = yaml_data['global']
global_obj = yaml_data.pop('global')
except KeyError:
desc = configexc.ConfigErrorDesc(
"While loading data",
@ -156,6 +166,15 @@ class YamlConfig(QObject):
"Toplevel object is not a dict")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
# FIXME:conf test this
try:
yaml_data.pop('config_version')
except KeyError:
desc = configexc.ConfigErrorDesc(
"While loading data",
"Toplevel object does not contain 'config_version' key")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
if not isinstance(global_obj, dict):
desc = configexc.ConfigErrorDesc(
"While loading data",
@ -163,6 +182,7 @@ class YamlConfig(QObject):
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
self._values = global_obj
self._per_url_values = yaml_data
self._dirty = False
self._handle_migrations()
@ -170,6 +190,7 @@ class YamlConfig(QObject):
def _handle_migrations(self):
"""Migrate older configs to the newest format."""
# FIXME:conf handle per-URL settings
# Simple renamed/deleted options
for name in list(self._values):
if name in configdata.MIGRATIONS.renamed:
@ -196,23 +217,35 @@ class YamlConfig(QObject):
def _validate(self):
"""Make sure all settings exist."""
unknown = set(self._values) - set(configdata.DATA)
unknown = []
for _pattern, name, value in self:
# FIXME:conf show pattern
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 unset(self, name):
def set_obj(self, name, value, *, pattern=None):
"""Set the given setting to the given value."""
values = self._get_values(pattern)
values[name] = value
def unset(self, name, *, pattern=None):
"""Remove the given option name if it's configured."""
values = self._get_values(pattern)
try:
del self._values[name]
del values[name]
except KeyError:
return
self._mark_changed()
def clear(self):
"""Clear all values from the YAML file."""
# FIXME:conf per-URL support?
self._values = []
self._mark_changed()

View File

@ -95,6 +95,17 @@ class UrlPattern:
self._init_path(parsed)
self._init_port(parsed)
def _to_tuple(self):
"""Get a pattern with information used for __eq__/__hash__."""
return (self._match_all, self._match_subdomains, self._scheme,
self._host, self._path, self._port)
def __hash__(self):
return hash(self._to_tuple())
def __eq__(self, other):
return self._to_tuple() == other._to_tuple()
def _fixup_pattern(self, pattern):
"""Make sure the given pattern is parseable by urllib.parse."""
if pattern.startswith('*:'): # Any scheme, but *:// is unparseable